@fragno-dev/core 0.2.0 → 0.2.2

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 (146) hide show
  1. package/.turbo/turbo-build.log +72 -62
  2. package/CHANGELOG.md +28 -0
  3. package/dist/api/api.d.ts +3 -2
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js +2 -1
  6. package/dist/api/api.js.map +1 -1
  7. package/dist/api/bind-services.d.ts +0 -1
  8. package/dist/api/bind-services.d.ts.map +1 -1
  9. package/dist/api/bind-services.js.map +1 -1
  10. package/dist/api/error.d.ts.map +1 -1
  11. package/dist/api/error.js.map +1 -1
  12. package/dist/api/fragment-definition-builder.d.ts +26 -44
  13. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  14. package/dist/api/fragment-definition-builder.js +15 -22
  15. package/dist/api/fragment-definition-builder.js.map +1 -1
  16. package/dist/api/fragment-instantiator.d.ts +51 -37
  17. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  18. package/dist/api/fragment-instantiator.js +74 -69
  19. package/dist/api/fragment-instantiator.js.map +1 -1
  20. package/dist/api/request-context-storage.d.ts +4 -0
  21. package/dist/api/request-context-storage.d.ts.map +1 -1
  22. package/dist/api/request-context-storage.js +6 -0
  23. package/dist/api/request-context-storage.js.map +1 -1
  24. package/dist/api/request-input-context.d.ts.map +1 -1
  25. package/dist/api/request-input-context.js.map +1 -1
  26. package/dist/api/request-middleware.d.ts +1 -1
  27. package/dist/api/request-middleware.d.ts.map +1 -1
  28. package/dist/api/request-middleware.js.map +1 -1
  29. package/dist/api/request-output-context.d.ts +1 -1
  30. package/dist/api/request-output-context.d.ts.map +1 -1
  31. package/dist/api/request-output-context.js.map +1 -1
  32. package/dist/api/route-caller.d.ts +30 -0
  33. package/dist/api/route-caller.d.ts.map +1 -0
  34. package/dist/api/route-caller.js +63 -0
  35. package/dist/api/route-caller.js.map +1 -0
  36. package/dist/api/route-handler-input-options.d.ts.map +1 -1
  37. package/dist/api/route.d.ts +1 -1
  38. package/dist/api/route.d.ts.map +1 -1
  39. package/dist/api/route.js.map +1 -1
  40. package/dist/api/shared-types.d.ts.map +1 -1
  41. package/dist/client/client-error.d.ts.map +1 -1
  42. package/dist/client/client-error.js.map +1 -1
  43. package/dist/client/client.d.ts +91 -52
  44. package/dist/client/client.d.ts.map +1 -1
  45. package/dist/client/client.js +25 -9
  46. package/dist/client/client.js.map +1 -1
  47. package/dist/client/client.svelte.d.ts +6 -5
  48. package/dist/client/client.svelte.d.ts.map +1 -1
  49. package/dist/client/client.svelte.js +10 -2
  50. package/dist/client/client.svelte.js.map +1 -1
  51. package/dist/client/internal/ndjson-streaming.js.map +1 -1
  52. package/dist/client/react.d.ts +5 -4
  53. package/dist/client/react.d.ts.map +1 -1
  54. package/dist/client/react.js +104 -12
  55. package/dist/client/react.js.map +1 -1
  56. package/dist/client/solid.d.ts +7 -5
  57. package/dist/client/solid.d.ts.map +1 -1
  58. package/dist/client/solid.js +23 -9
  59. package/dist/client/solid.js.map +1 -1
  60. package/dist/client/vanilla.d.ts +16 -4
  61. package/dist/client/vanilla.d.ts.map +1 -1
  62. package/dist/client/vanilla.js +21 -1
  63. package/dist/client/vanilla.js.map +1 -1
  64. package/dist/client/vue.d.ts +7 -5
  65. package/dist/client/vue.d.ts.map +1 -1
  66. package/dist/client/vue.js +18 -10
  67. package/dist/client/vue.js.map +1 -1
  68. package/dist/id.d.ts +2 -0
  69. package/dist/id.js +3 -0
  70. package/dist/internal/cuid.d.ts +16 -0
  71. package/dist/internal/cuid.d.ts.map +1 -0
  72. package/dist/internal/cuid.js +82 -0
  73. package/dist/internal/cuid.js.map +1 -0
  74. package/dist/mod-client.d.ts +5 -4
  75. package/dist/mod-client.d.ts.map +1 -1
  76. package/dist/mod-client.js +7 -5
  77. package/dist/mod-client.js.map +1 -1
  78. package/dist/mod.d.ts +6 -5
  79. package/dist/mod.js +2 -1
  80. package/dist/runtime.js +1 -1
  81. package/dist/runtime.js.map +1 -1
  82. package/dist/test/test.d.ts +6 -6
  83. package/dist/test/test.d.ts.map +1 -1
  84. package/dist/test/test.js.map +1 -1
  85. package/dist/util/ssr.js.map +1 -1
  86. package/package.json +24 -40
  87. package/src/api/api.test.ts +3 -1
  88. package/src/api/api.ts +6 -0
  89. package/src/api/bind-services.ts +0 -5
  90. package/src/api/error.ts +1 -0
  91. package/src/api/fragment-definition-builder.extend.test.ts +2 -1
  92. package/src/api/fragment-definition-builder.test.ts +2 -1
  93. package/src/api/fragment-definition-builder.ts +49 -124
  94. package/src/api/fragment-instantiator.test.ts +92 -233
  95. package/src/api/fragment-instantiator.ts +228 -196
  96. package/src/api/fragment-services.test.ts +1 -0
  97. package/src/api/internal/path-runtime.test.ts +1 -0
  98. package/src/api/internal/path-type.test.ts +3 -1
  99. package/src/api/internal/route.test.ts +1 -0
  100. package/src/api/request-context-storage.ts +7 -0
  101. package/src/api/request-input-context.test.ts +4 -2
  102. package/src/api/request-input-context.ts +2 -1
  103. package/src/api/request-middleware.test.ts +9 -14
  104. package/src/api/request-middleware.ts +3 -2
  105. package/src/api/request-output-context.test.ts +3 -1
  106. package/src/api/request-output-context.ts +2 -1
  107. package/src/api/route-caller.test.ts +195 -0
  108. package/src/api/route-caller.ts +167 -0
  109. package/src/api/route-handler-input-options.ts +2 -1
  110. package/src/api/route.test.ts +4 -2
  111. package/src/api/route.ts +2 -1
  112. package/src/api/shared-types.ts +2 -1
  113. package/src/client/client-builder.test.ts +4 -2
  114. package/src/client/client-error.test.ts +2 -1
  115. package/src/client/client-error.ts +1 -1
  116. package/src/client/client-types.test.ts +19 -5
  117. package/src/client/client.ssr.test.ts +6 -4
  118. package/src/client/client.svelte.test.ts +18 -9
  119. package/src/client/client.svelte.ts +38 -13
  120. package/src/client/client.test.ts +49 -10
  121. package/src/client/client.ts +291 -141
  122. package/src/client/internal/ndjson-streaming.test.ts +6 -3
  123. package/src/client/internal/ndjson-streaming.ts +1 -0
  124. package/src/client/react.test.ts +176 -6
  125. package/src/client/react.ts +226 -31
  126. package/src/client/solid.test.ts +29 -5
  127. package/src/client/solid.ts +60 -22
  128. package/src/client/vanilla.test.ts +148 -6
  129. package/src/client/vanilla.ts +63 -9
  130. package/src/client/vue.test.ts +223 -84
  131. package/src/client/vue.ts +57 -30
  132. package/src/id.ts +1 -0
  133. package/src/internal/cuid.test.ts +164 -0
  134. package/src/internal/cuid.ts +133 -0
  135. package/src/mod-client.ts +4 -2
  136. package/src/mod.ts +3 -2
  137. package/src/runtime.ts +1 -1
  138. package/src/test/test.test.ts +4 -2
  139. package/src/test/test.ts +7 -9
  140. package/src/util/async.test.ts +1 -0
  141. package/src/util/content-type.test.ts +1 -0
  142. package/src/util/nanostores.test.ts +3 -1
  143. package/src/util/ssr.ts +1 -0
  144. package/tsconfig.json +1 -1
  145. package/tsdown.config.ts +1 -0
  146. package/vitest.config.ts +2 -1
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect, expectTypeOf } from "vitest";
2
+
2
3
  import { defineFragment } from "./fragment-definition-builder";
3
4
  import { instantiate } from "./fragment-instantiator";
4
5
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { buildPath, extractPathParams, matchPathParams } from "./path";
3
4
 
4
5
  describe("extractPathParams (runtime names)", () => {
@@ -1,4 +1,7 @@
1
1
  import { test, expect, expectTypeOf } from "vitest";
2
+
3
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
2
5
  import type {
3
6
  ExtractPathParams,
4
7
  ExtractPathParamNames,
@@ -9,7 +12,6 @@ import type {
9
12
  MaybeExtractPathParamsOrWiden,
10
13
  QueryParamsHint,
11
14
  } from "./path";
12
- import type { StandardSchemaV1 } from "@standard-schema/spec";
13
15
 
14
16
  // Type-only tests using expectTypeOf from vitest
15
17
  test("ExtractPathParams type tests", () => {
@@ -1,4 +1,5 @@
1
1
  import { test, expect } from "vitest";
2
+
2
3
  import { getMountRoute } from "./route";
3
4
 
4
5
  test("getMountRoute - default mount route", () => {
@@ -28,6 +28,13 @@ export class RequestContextStorage<TRequestStorage> {
28
28
  return this.#storage.run(data, callback);
29
29
  }
30
30
 
31
+ /**
32
+ * Check whether a store is currently active.
33
+ */
34
+ hasStore(): boolean {
35
+ return this.#storage.getStore() !== undefined;
36
+ }
37
+
31
38
  /**
32
39
  * Get the current stored data from AsyncLocalStorage.
33
40
  * @throws an error if called outside of a run() callback.
@@ -1,8 +1,10 @@
1
1
  import { test, expect, describe } from "vitest";
2
- import { RequestInputContext } from "./request-input-context";
3
- import { FragnoApiValidationError } from "./api";
2
+
4
3
  import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
5
+ import { FragnoApiValidationError } from "./api";
5
6
  import { MutableRequestState } from "./mutable-request-state";
7
+ import { RequestInputContext } from "./request-input-context";
6
8
 
7
9
  // Mock schema implementations for testing
8
10
  const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
@@ -1,6 +1,7 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import type { ExtractPathParams } from "./internal/path";
2
+
3
3
  import { FragnoApiValidationError, type HTTPMethod } from "./api";
4
+ import type { ExtractPathParams } from "./internal/path";
4
5
  import type { MutableRequestState } from "./mutable-request-state";
5
6
 
6
7
  export type RequestBodyType =
@@ -1,9 +1,11 @@
1
1
  import { test, expect, describe, expectTypeOf } from "vitest";
2
- import { defineFragment } from "./fragment-definition-builder";
3
- import { instantiate } from "./fragment-instantiator";
4
- import { defineRoute, defineRoutes } from "./route";
2
+
5
3
  import { z } from "zod";
4
+
6
5
  import { FragnoApiValidationError } from "./error";
6
+ import { defineFragment } from "./fragment-definition-builder";
7
+ import { instantiate } from "./fragment-instantiator";
8
+ import { defineRoute } from "./route";
7
9
 
8
10
  describe("Request Middleware", () => {
9
11
  test("middleware can intercept and return early", async () => {
@@ -276,11 +278,10 @@ describe("Request Middleware", () => {
276
278
  expect(middlewareCalled).toBe(true);
277
279
  });
278
280
 
279
- test("ifMatchesRoute - supports internal linked fragment routes", async () => {
281
+ test("ifMatchesRoute - supports internal routes", async () => {
280
282
  const config = {};
281
283
 
282
- const internalDef = defineFragment("internal").build();
283
- const internalRoutes = defineRoutes(internalDef).create(({ defineRoute }) => [
284
+ const internalRoutes = [
284
285
  defineRoute({
285
286
  method: "GET",
286
287
  path: "/status",
@@ -288,16 +289,10 @@ describe("Request Middleware", () => {
288
289
  return json({ ok: true });
289
290
  },
290
291
  }),
291
- ]);
292
+ ] as const;
292
293
 
293
294
  const definition = defineFragment<typeof config>("test-lib")
294
- .withLinkedFragment("_fragno_internal", ({ config, options }) => {
295
- return instantiate(internalDef)
296
- .withConfig(config)
297
- .withOptions(options)
298
- .withRoutes([internalRoutes])
299
- .build();
300
- })
295
+ .withInternalRoutes(internalRoutes)
301
296
  .build();
302
297
 
303
298
  const instance = instantiate(definition)
@@ -1,11 +1,12 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
2
3
  import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
3
4
  import type { HTTPMethod } from "./api";
4
5
  import type { ExtractPathParams } from "./internal/path";
5
- import type { AnyFragnoRouteConfig } from "./route";
6
+ import { MutableRequestState } from "./mutable-request-state";
6
7
  import { RequestInputContext } from "./request-input-context";
7
8
  import { OutputContext, RequestOutputContext } from "./request-output-context";
8
- import { MutableRequestState } from "./mutable-request-state";
9
+ import type { AnyFragnoRouteConfig } from "./route";
9
10
 
10
11
  export type FragnoMiddlewareCallback<
11
12
  TRoutes extends readonly AnyFragnoRouteConfig[],
@@ -1,7 +1,9 @@
1
1
  import { test, expect, describe, vi } from "vitest";
2
- import { RequestOutputContext } from "./request-output-context";
2
+
3
3
  import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
4
5
  import { ResponseStream } from "./internal/response-stream";
6
+ import { RequestOutputContext } from "./request-output-context";
5
7
 
6
8
  // Mock schema implementations for testing
7
9
  const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
@@ -1,7 +1,8 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
2
3
  import type { ContentlessStatusCode, StatusCode } from "../http/http-status";
3
- import { ResponseStream } from "./internal/response-stream";
4
4
  import type { InferOrUnknown } from "../util/types-util";
5
+ import { ResponseStream } from "./internal/response-stream";
5
6
 
6
7
  export type ResponseData = string | ArrayBuffer | ReadableStream | Uint8Array<ArrayBuffer>;
7
8
 
@@ -0,0 +1,195 @@
1
+ import { describe, expect, expectTypeOf, test } from "vitest";
2
+
3
+ import { z } from "zod";
4
+
5
+ import type { FragnoResponse } from "./fragno-response";
6
+ import { defineRoute } from "./route";
7
+ import { createRouteCaller } from "./route-caller";
8
+
9
+ describe("createRouteCaller", () => {
10
+ test("replaces inherited content-type with application/json for object bodies", async () => {
11
+ let capturedRequest: Request | null = null;
12
+
13
+ const callRoute = createRouteCaller<{
14
+ callRoute: (
15
+ method: "POST",
16
+ path: "/test",
17
+ input: { body: { hello: string } },
18
+ ) => Promise<unknown>;
19
+ }>({
20
+ baseUrl: "https://example.com/app",
21
+ baseHeaders: {
22
+ "content-type": "application/x-www-form-urlencoded",
23
+ },
24
+ fetch: async (request) => {
25
+ capturedRequest = request;
26
+ return new Response(JSON.stringify({ ok: true }), {
27
+ headers: { "content-type": "application/json" },
28
+ });
29
+ },
30
+ });
31
+
32
+ await callRoute("POST", "/test", {
33
+ body: { hello: "world" },
34
+ });
35
+
36
+ if (!capturedRequest) {
37
+ throw new Error("Expected fetch to receive a request.");
38
+ }
39
+
40
+ const request = capturedRequest as Request;
41
+ expect(request.headers.get("content-type")).toBe("application/json");
42
+ expect(await request.text()).toBe('{"hello":"world"}');
43
+ });
44
+
45
+ test("keeps an explicit application/octet-stream content-type for binary bodies", async () => {
46
+ let capturedRequest: Request | null = null;
47
+
48
+ const callRoute = createRouteCaller<{
49
+ callRoute: (
50
+ method: "POST",
51
+ path: "/binary",
52
+ input: { body: ArrayBuffer; headers: HeadersInit },
53
+ ) => Promise<unknown>;
54
+ }>({
55
+ baseUrl: "https://example.com/app",
56
+ baseHeaders: {
57
+ "content-type": "multipart/form-data; boundary=---original",
58
+ },
59
+ fetch: async (request) => {
60
+ capturedRequest = request;
61
+ return new Response(JSON.stringify({ ok: true }), {
62
+ headers: { "content-type": "application/json" },
63
+ });
64
+ },
65
+ });
66
+
67
+ await callRoute("POST", "/binary", {
68
+ body: new Uint8Array([1, 2, 3]).buffer,
69
+ headers: {
70
+ "content-type": "application/octet-stream",
71
+ },
72
+ });
73
+
74
+ if (!capturedRequest) {
75
+ throw new Error("Expected fetch to receive a request.");
76
+ }
77
+
78
+ const request = capturedRequest as Request;
79
+ expect(request.headers.get("content-type")).toBe("application/octet-stream");
80
+ expect(new Uint8Array(await request.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
81
+ });
82
+
83
+ test("infers route-specific inputs and outputs from fragment routes", async () => {
84
+ const routes = [
85
+ defineRoute({
86
+ method: "GET",
87
+ path: "/threads",
88
+ outputSchema: z.object({
89
+ threads: z.array(z.object({ id: z.string() })),
90
+ hasNextPage: z.boolean(),
91
+ }),
92
+ handler: async (_ctx, { json }) =>
93
+ json({ threads: [{ id: "thread-1" }], hasNextPage: false }),
94
+ }),
95
+ defineRoute({
96
+ method: "GET",
97
+ path: "/threads/:threadId/messages",
98
+ outputSchema: z.object({
99
+ messages: z.array(z.object({ id: z.string(), threadId: z.string() })),
100
+ cursor: z.string().optional(),
101
+ hasNextPage: z.boolean(),
102
+ }),
103
+ handler: async ({ pathParams, query }, { json }) =>
104
+ json({
105
+ messages: [{ id: "message-1", threadId: pathParams.threadId }],
106
+ cursor: query.get("cursor") ?? undefined,
107
+ hasNextPage: false,
108
+ }),
109
+ }),
110
+ defineRoute({
111
+ method: "POST",
112
+ path: "/threads/:threadId/reply",
113
+ inputSchema: z.object({ text: z.string() }),
114
+ outputSchema: z.object({ ok: z.boolean() }),
115
+ handler: async (_ctx, { json }) => json({ ok: true }),
116
+ }),
117
+ ] as const;
118
+
119
+ type FakeFragment = {
120
+ routes: typeof routes;
121
+ };
122
+
123
+ const callRoute = createRouteCaller<FakeFragment>({
124
+ baseUrl: "https://example.com/app",
125
+ mountRoute: "/api",
126
+ fetch: async (request) => {
127
+ const url = new URL(request.url);
128
+
129
+ if (url.pathname === "/api/threads") {
130
+ return Response.json({
131
+ threads: [{ id: "thread-1" }],
132
+ hasNextPage: false,
133
+ });
134
+ }
135
+
136
+ if (url.pathname === "/api/threads/thread-1/messages") {
137
+ return Response.json({
138
+ messages: [{ id: "message-1", threadId: "thread-1" }],
139
+ cursor: url.searchParams.get("cursor") ?? undefined,
140
+ hasNextPage: false,
141
+ });
142
+ }
143
+
144
+ if (url.pathname === "/api/threads/thread-1/reply") {
145
+ const body = (await request.json()) as { text: string };
146
+ return Response.json({ ok: body.text === "hello" });
147
+ }
148
+
149
+ return Response.json({ message: "Not found", code: "NOT_FOUND" }, { status: 404 });
150
+ },
151
+ });
152
+
153
+ const listResponse = await callRoute("GET", "/threads", {
154
+ query: { cursor: "cursor-1" },
155
+ });
156
+ const messagesResponse = await callRoute("GET", "/threads/:threadId/messages", {
157
+ pathParams: { threadId: "thread-1" },
158
+ query: { cursor: "cursor-1" },
159
+ });
160
+ const replyResponse = await callRoute("POST", "/threads/:threadId/reply", {
161
+ pathParams: { threadId: "thread-1" },
162
+ body: { text: "hello" },
163
+ });
164
+
165
+ expectTypeOf<Extract<typeof listResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
166
+ threads: { id: string }[];
167
+ hasNextPage: boolean;
168
+ }>();
169
+ expectTypeOf<Extract<typeof messagesResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
170
+ messages: { id: string; threadId: string }[];
171
+ cursor?: string | undefined;
172
+ hasNextPage: boolean;
173
+ }>();
174
+ expectTypeOf<Extract<typeof replyResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
175
+ ok: boolean;
176
+ }>();
177
+ expectTypeOf<typeof listResponse>().toExtend<FragnoResponse<unknown>>();
178
+
179
+ expect(listResponse.type).toBe("json");
180
+ if (listResponse.type === "json") {
181
+ expect(listResponse.data.threads[0]?.id).toBe("thread-1");
182
+ }
183
+
184
+ expect(messagesResponse.type).toBe("json");
185
+ if (messagesResponse.type === "json") {
186
+ expect(messagesResponse.data.messages[0]?.threadId).toBe("thread-1");
187
+ expect(messagesResponse.data.cursor).toBe("cursor-1");
188
+ }
189
+
190
+ expect(replyResponse.type).toBe("json");
191
+ if (replyResponse.type === "json") {
192
+ expect(replyResponse.data.ok).toBe(true);
193
+ }
194
+ });
195
+ });
@@ -0,0 +1,167 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
3
+ import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
4
+ import type { InferOrUnknown } from "../util/types-util";
5
+ import type { HTTPMethod } from "./api";
6
+ import type { FragnoResponse } from "./fragno-response";
7
+ import { parseFragnoResponse } from "./fragno-response";
8
+ import { buildPath, type ExtractPathParams } from "./internal/path";
9
+ import type { AnyFragnoRouteConfig } from "./route";
10
+ import type { RouteHandlerInputOptions } from "./route-handler-input-options";
11
+
12
+ export type RouteCallerConfig = {
13
+ baseUrl: string | URL;
14
+ mountRoute?: string;
15
+ baseHeaders?: HeadersInit;
16
+ fetch: (request: Request) => Promise<Response>;
17
+ redirect?: RequestRedirect;
18
+ };
19
+
20
+ type ArrayBufferViewOfArrayBuffer = ArrayBufferView & { buffer: ArrayBuffer };
21
+
22
+ type FragmentLike = {
23
+ routes?: readonly AnyFragnoRouteConfig[];
24
+ callRoute?: (...args: never[]) => Promise<unknown>;
25
+ };
26
+
27
+ type RouteCallerPath<
28
+ TRoutes extends readonly AnyFragnoRouteConfig[],
29
+ TMethod extends HTTPMethod,
30
+ > = [ExtractRoutePath<TRoutes, TMethod>] extends [never]
31
+ ? string
32
+ : ExtractRoutePath<TRoutes, TMethod>;
33
+
34
+ type RouteCallerMatch<
35
+ TRoutes extends readonly AnyFragnoRouteConfig[],
36
+ TMethod extends HTTPMethod,
37
+ TPath extends string,
38
+ > = [ExtractRouteByPath<TRoutes, TPath, TMethod>] extends [never]
39
+ ? AnyFragnoRouteConfig
40
+ : ExtractRouteByPath<TRoutes, TPath, TMethod>;
41
+
42
+ export type RouteCallerForFragment<TFragment extends FragmentLike> = TFragment extends {
43
+ routes: infer TRoutes extends readonly AnyFragnoRouteConfig[];
44
+ }
45
+ ? <TMethod extends HTTPMethod, TPath extends RouteCallerPath<TRoutes, TMethod>>(
46
+ method: TMethod,
47
+ path: TPath,
48
+ inputOptions?: RouteHandlerInputOptions<
49
+ TPath,
50
+ RouteCallerMatch<TRoutes, TMethod, TPath>["inputSchema"]
51
+ >,
52
+ ) => Promise<
53
+ FragnoResponse<
54
+ InferOrUnknown<NonNullable<RouteCallerMatch<TRoutes, TMethod, TPath>["outputSchema"]>>
55
+ >
56
+ >
57
+ : TFragment extends { callRoute: (...args: never[]) => Promise<unknown> }
58
+ ? TFragment["callRoute"]
59
+ : never;
60
+
61
+ function isArrayBufferView(value: unknown): value is ArrayBufferViewOfArrayBuffer {
62
+ return ArrayBuffer.isView(value) && value.buffer instanceof ArrayBuffer;
63
+ }
64
+
65
+ function buildMountedPath(mountRoute: string, pathname: string): string {
66
+ const normalizedMount =
67
+ mountRoute === "/" ? "" : mountRoute.endsWith("/") ? mountRoute.slice(0, -1) : mountRoute;
68
+ const pathPart = pathname.startsWith("/") ? pathname : `/${pathname}`;
69
+ if (!normalizedMount) {
70
+ return pathPart;
71
+ }
72
+ const mountPart = normalizedMount.startsWith("/") ? normalizedMount : `/${normalizedMount}`;
73
+ return `${mountPart}${pathPart}`;
74
+ }
75
+
76
+ export function createRouteCaller<TFragment extends FragmentLike>(
77
+ config: RouteCallerConfig,
78
+ ): RouteCallerForFragment<TFragment> {
79
+ const { baseUrl, fetch } = config;
80
+ const mountRoute = config.mountRoute ?? "";
81
+ const baseHeaders = config.baseHeaders ? new Headers(config.baseHeaders) : undefined;
82
+ const redirect = config.redirect ?? "manual";
83
+
84
+ const callRoute = async <TPath extends string>(
85
+ method: HTTPMethod,
86
+ path: TPath,
87
+ inputOptions?: RouteHandlerInputOptions<TPath, StandardSchemaV1 | undefined>,
88
+ ): Promise<FragnoResponse<unknown>> => {
89
+ const headers = baseHeaders ? new Headers(baseHeaders) : new Headers();
90
+ const explicitHeaders = inputOptions?.headers
91
+ ? inputOptions.headers instanceof Headers
92
+ ? inputOptions.headers
93
+ : new Headers(inputOptions.headers)
94
+ : null;
95
+
96
+ if (explicitHeaders) {
97
+ for (const [key, value] of explicitHeaders.entries()) {
98
+ headers.set(key, value);
99
+ }
100
+ }
101
+
102
+ const hasExplicitContentType = explicitHeaders?.has("content-type") ?? false;
103
+
104
+ const searchParams =
105
+ inputOptions?.query instanceof URLSearchParams
106
+ ? new URLSearchParams(inputOptions.query)
107
+ : new URLSearchParams();
108
+
109
+ if (inputOptions?.query && !(inputOptions.query instanceof URLSearchParams)) {
110
+ for (const [key, value] of Object.entries(inputOptions.query)) {
111
+ if (value !== undefined) {
112
+ searchParams.set(key, value);
113
+ }
114
+ }
115
+ }
116
+
117
+ const pathParams = (inputOptions?.pathParams ?? {}) as ExtractPathParams<TPath>;
118
+ const pathname = buildPath(path, pathParams);
119
+ const url = new URL(buildMountedPath(mountRoute, pathname), baseUrl);
120
+ url.search = searchParams.toString();
121
+
122
+ let body: BodyInit | undefined;
123
+ if (inputOptions && "body" in inputOptions) {
124
+ const rawBody = (inputOptions as { body?: unknown }).body;
125
+
126
+ if (rawBody instanceof FormData || rawBody instanceof Blob) {
127
+ body = rawBody;
128
+ if (!hasExplicitContentType) {
129
+ headers.delete("content-type");
130
+ }
131
+ } else if (rawBody instanceof ReadableStream) {
132
+ body = rawBody;
133
+ if (!hasExplicitContentType) {
134
+ headers.delete("content-type");
135
+ }
136
+ } else if (rawBody instanceof ArrayBuffer) {
137
+ body = rawBody;
138
+ if (!hasExplicitContentType) {
139
+ headers.delete("content-type");
140
+ }
141
+ } else if (isArrayBufferView(rawBody)) {
142
+ body = rawBody;
143
+ if (!hasExplicitContentType) {
144
+ headers.delete("content-type");
145
+ }
146
+ } else if (rawBody !== undefined) {
147
+ body = JSON.stringify(rawBody);
148
+ if (!hasExplicitContentType) {
149
+ headers.set("content-type", "application/json");
150
+ }
151
+ }
152
+ }
153
+
154
+ const response = await fetch(
155
+ new Request(url, {
156
+ method,
157
+ headers,
158
+ body,
159
+ redirect,
160
+ }),
161
+ );
162
+
163
+ return parseFragnoResponse(response);
164
+ };
165
+
166
+ return callRoute as RouteCallerForFragment<TFragment>;
167
+ }
@@ -1,6 +1,7 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import type { ExtractPathParams } from "./internal/path";
2
+
3
3
  import type { InferOr } from "../util/types-util";
4
+ import type { ExtractPathParams } from "./internal/path";
4
5
 
5
6
  /**
6
7
  * Options for calling a route handler
@@ -1,8 +1,10 @@
1
1
  import { test, expect, expectTypeOf, describe } from "vitest";
2
- import { defineRoute, defineRoutes } from "./route";
3
- import { defineFragment } from "./fragment-definition-builder";
2
+
4
3
  import { z } from "zod";
5
4
 
5
+ import { defineFragment } from "./fragment-definition-builder";
6
+ import { defineRoute, defineRoutes } from "./route";
7
+
6
8
  describe("defineRoute", () => {
7
9
  test("defineRoute no inputSchema", () => {
8
10
  const route = defineRoute({
package/src/api/route.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // oxlint-disable no-explicit-any
2
2
 
3
3
  import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+
4
5
  import type { FragnoRouteConfig, HTTPMethod, RequestThisContext } from "./api";
5
- import type { FragmentDefinition } from "./fragment-definition-builder";
6
6
  import type { BoundServices } from "./bind-services";
7
+ import type { FragmentDefinition } from "./fragment-definition-builder";
7
8
 
8
9
  export type AnyFragnoRouteConfig = FragnoRouteConfig<HTTPMethod, string, any, any, any, any, any>;
9
10
 
@@ -1,6 +1,7 @@
1
- import type { HTTPMethod } from "./api";
2
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
3
2
 
3
+ import type { HTTPMethod } from "./api";
4
+
4
5
  /**
5
6
  * Public configuration for Fragno fragments on the server side.
6
7
  */
@@ -1,9 +1,11 @@
1
1
  import { test, expect, expectTypeOf, describe } from "vitest";
2
+
2
3
  import { z } from "zod";
3
- import { createClientBuilder } from "./client";
4
- import { defineRoute } from "../api/route";
4
+
5
5
  import { defineFragment } from "../api/fragment-definition-builder";
6
+ import { defineRoute } from "../api/route";
6
7
  import type { FragnoPublicClientConfig } from "../api/shared-types";
8
+ import { createClientBuilder } from "./client";
7
9
 
8
10
  // Test route configurations
9
11
  const testFragment = defineFragment("test-fragment").build();
@@ -1,7 +1,8 @@
1
1
  import { test, expect, describe } from "vitest";
2
- import { FragnoClientApiError, FragnoClientUnknownApiError } from "./client-error";
2
+
3
3
  import { FragnoApiError } from "../api/error";
4
4
  import { RequestOutputContext } from "../api/request-output-context";
5
+ import { FragnoClientApiError, FragnoClientUnknownApiError } from "./client-error";
5
6
 
6
7
  describe("Error Conversion", () => {
7
8
  test("should convert API error to client error", async () => {
@@ -112,7 +112,7 @@ export class FragnoClientApiError<
112
112
  * The type is `TErrorCode` (the set of known error codes for this route), but may also be a string
113
113
  * for forward compatibility with future error codes.
114
114
  */
115
- get code(): TErrorCode | (string & {}) {
115
+ override get code(): TErrorCode | (string & {}) {
116
116
  return super.code as TErrorCode | (string & {});
117
117
  }
118
118
 
@@ -1,18 +1,22 @@
1
1
  import { test, expectTypeOf, describe } from "vitest";
2
+
2
3
  import { z } from "zod";
4
+
5
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
6
+
3
7
  import { type FragnoRouteConfig, type HTTPMethod } from "../api/api";
4
- import { defineRoute } from "../api/route";
8
+ import { type AnyFragnoRouteConfig, defineRoute } from "../api/route";
5
9
  import type {
6
10
  ExtractGetRoutes,
7
11
  ExtractGetRoutePaths,
8
12
  ExtractOutputSchemaForPath,
9
13
  ExtractRouteByPath,
14
+ ExtractRoutePath,
10
15
  IsValidGetRoutePath,
11
16
  ValidateGetRoutePath,
12
17
  HasGetRoutes,
13
18
  FragnoClientMutatorData,
14
19
  } from "./client";
15
- import type { StandardSchemaV1 } from "@standard-schema/spec";
16
20
 
17
21
  // Test route configurations for type testing
18
22
  const _testRoutes = [
@@ -150,12 +154,12 @@ test("ExtractOutputSchemaForPath type tests", () => {
150
154
 
151
155
  // Note: Routes without output schema have complex type inference, skipping direct test
152
156
 
153
- // Should be never for non-existent path
157
+ // Non-existent paths currently widen to unknown, while routes without an output schema stay optional.
154
158
  type NonExistentSchema = ExtractOutputSchemaForPath<typeof _testRoutes, "/nonexistent">;
155
- expectTypeOf<NonExistentSchema>().toEqualTypeOf<never>();
159
+ expectTypeOf<NonExistentSchema>().toEqualTypeOf<unknown>();
156
160
 
157
161
  type PathWithNoSchema = ExtractOutputSchemaForPath<typeof _testRoutes, "/home">;
158
- expectTypeOf<PathWithNoSchema>().toEqualTypeOf<StandardSchemaV1<unknown, unknown> | undefined>();
162
+ expectTypeOf<PathWithNoSchema>().toEqualTypeOf<StandardSchemaV1<unknown, unknown>>();
159
163
  });
160
164
 
161
165
  test("IsValidGetRoutePath type tests", () => {
@@ -343,6 +347,16 @@ test("GET route with outputSchema", () => {
343
347
  expectTypeOf<ConstPaths>().toEqualTypeOf<"/test">();
344
348
  });
345
349
 
350
+ test("route extraction falls back gracefully for widened route types", () => {
351
+ type BroadRoutes = readonly AnyFragnoRouteConfig[];
352
+
353
+ expectTypeOf<ExtractRoutePath<BroadRoutes, "POST">>().toEqualTypeOf<string>();
354
+ expectTypeOf<ExtractGetRoutePaths<BroadRoutes>>().toEqualTypeOf<string>();
355
+ expectTypeOf<
356
+ ExtractRouteByPath<BroadRoutes, "/users/:id", "POST">
357
+ >().toExtend<AnyFragnoRouteConfig>();
358
+ });
359
+
346
360
  describe("ExtractRouteByPath", () => {
347
361
  const _fragmentConfig = {
348
362
  name: "test-fragment",