@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.
- package/.turbo/turbo-build.log +72 -62
- package/CHANGELOG.md +28 -0
- package/dist/api/api.d.ts +3 -2
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js +2 -1
- package/dist/api/api.js.map +1 -1
- package/dist/api/bind-services.d.ts +0 -1
- package/dist/api/bind-services.d.ts.map +1 -1
- package/dist/api/bind-services.js.map +1 -1
- package/dist/api/error.d.ts.map +1 -1
- package/dist/api/error.js.map +1 -1
- package/dist/api/fragment-definition-builder.d.ts +26 -44
- package/dist/api/fragment-definition-builder.d.ts.map +1 -1
- package/dist/api/fragment-definition-builder.js +15 -22
- package/dist/api/fragment-definition-builder.js.map +1 -1
- package/dist/api/fragment-instantiator.d.ts +51 -37
- package/dist/api/fragment-instantiator.d.ts.map +1 -1
- package/dist/api/fragment-instantiator.js +74 -69
- package/dist/api/fragment-instantiator.js.map +1 -1
- package/dist/api/request-context-storage.d.ts +4 -0
- package/dist/api/request-context-storage.d.ts.map +1 -1
- package/dist/api/request-context-storage.js +6 -0
- package/dist/api/request-context-storage.js.map +1 -1
- package/dist/api/request-input-context.d.ts.map +1 -1
- 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/request-output-context.d.ts +1 -1
- package/dist/api/request-output-context.d.ts.map +1 -1
- package/dist/api/request-output-context.js.map +1 -1
- package/dist/api/route-caller.d.ts +30 -0
- package/dist/api/route-caller.d.ts.map +1 -0
- package/dist/api/route-caller.js +63 -0
- package/dist/api/route-caller.js.map +1 -0
- package/dist/api/route-handler-input-options.d.ts.map +1 -1
- package/dist/api/route.d.ts +1 -1
- package/dist/api/route.d.ts.map +1 -1
- package/dist/api/route.js.map +1 -1
- package/dist/api/shared-types.d.ts.map +1 -1
- package/dist/client/client-error.d.ts.map +1 -1
- package/dist/client/client-error.js.map +1 -1
- package/dist/client/client.d.ts +91 -52
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +25 -9
- package/dist/client/client.js.map +1 -1
- package/dist/client/client.svelte.d.ts +6 -5
- package/dist/client/client.svelte.d.ts.map +1 -1
- package/dist/client/client.svelte.js +10 -2
- package/dist/client/client.svelte.js.map +1 -1
- package/dist/client/internal/ndjson-streaming.js.map +1 -1
- package/dist/client/react.d.ts +5 -4
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +104 -12
- package/dist/client/react.js.map +1 -1
- package/dist/client/solid.d.ts +7 -5
- package/dist/client/solid.d.ts.map +1 -1
- package/dist/client/solid.js +23 -9
- package/dist/client/solid.js.map +1 -1
- package/dist/client/vanilla.d.ts +16 -4
- package/dist/client/vanilla.d.ts.map +1 -1
- package/dist/client/vanilla.js +21 -1
- package/dist/client/vanilla.js.map +1 -1
- package/dist/client/vue.d.ts +7 -5
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +18 -10
- package/dist/client/vue.js.map +1 -1
- package/dist/id.d.ts +2 -0
- package/dist/id.js +3 -0
- package/dist/internal/cuid.d.ts +16 -0
- package/dist/internal/cuid.d.ts.map +1 -0
- package/dist/internal/cuid.js +82 -0
- package/dist/internal/cuid.js.map +1 -0
- package/dist/mod-client.d.ts +5 -4
- package/dist/mod-client.d.ts.map +1 -1
- package/dist/mod-client.js +7 -5
- package/dist/mod-client.js.map +1 -1
- package/dist/mod.d.ts +6 -5
- package/dist/mod.js +2 -1
- package/dist/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/test/test.d.ts +6 -6
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js.map +1 -1
- package/dist/util/ssr.js.map +1 -1
- package/package.json +24 -40
- package/src/api/api.test.ts +3 -1
- package/src/api/api.ts +6 -0
- package/src/api/bind-services.ts +0 -5
- package/src/api/error.ts +1 -0
- package/src/api/fragment-definition-builder.extend.test.ts +2 -1
- package/src/api/fragment-definition-builder.test.ts +2 -1
- package/src/api/fragment-definition-builder.ts +49 -124
- package/src/api/fragment-instantiator.test.ts +92 -233
- package/src/api/fragment-instantiator.ts +228 -196
- package/src/api/fragment-services.test.ts +1 -0
- package/src/api/internal/path-runtime.test.ts +1 -0
- package/src/api/internal/path-type.test.ts +3 -1
- package/src/api/internal/route.test.ts +1 -0
- package/src/api/request-context-storage.ts +7 -0
- package/src/api/request-input-context.test.ts +4 -2
- package/src/api/request-input-context.ts +2 -1
- package/src/api/request-middleware.test.ts +9 -14
- package/src/api/request-middleware.ts +3 -2
- package/src/api/request-output-context.test.ts +3 -1
- package/src/api/request-output-context.ts +2 -1
- package/src/api/route-caller.test.ts +195 -0
- package/src/api/route-caller.ts +167 -0
- package/src/api/route-handler-input-options.ts +2 -1
- package/src/api/route.test.ts +4 -2
- package/src/api/route.ts +2 -1
- package/src/api/shared-types.ts +2 -1
- package/src/client/client-builder.test.ts +4 -2
- package/src/client/client-error.test.ts +2 -1
- package/src/client/client-error.ts +1 -1
- package/src/client/client-types.test.ts +19 -5
- package/src/client/client.ssr.test.ts +6 -4
- package/src/client/client.svelte.test.ts +18 -9
- package/src/client/client.svelte.ts +38 -13
- package/src/client/client.test.ts +49 -10
- package/src/client/client.ts +291 -141
- package/src/client/internal/ndjson-streaming.test.ts +6 -3
- package/src/client/internal/ndjson-streaming.ts +1 -0
- package/src/client/react.test.ts +176 -6
- package/src/client/react.ts +226 -31
- package/src/client/solid.test.ts +29 -5
- package/src/client/solid.ts +60 -22
- package/src/client/vanilla.test.ts +148 -6
- package/src/client/vanilla.ts +63 -9
- package/src/client/vue.test.ts +223 -84
- package/src/client/vue.ts +57 -30
- package/src/id.ts +1 -0
- package/src/internal/cuid.test.ts +164 -0
- package/src/internal/cuid.ts +133 -0
- package/src/mod-client.ts +4 -2
- package/src/mod.ts +3 -2
- package/src/runtime.ts +1 -1
- package/src/test/test.test.ts +4 -2
- package/src/test/test.ts +7 -9
- package/src/util/async.test.ts +1 -0
- package/src/util/content-type.test.ts +1 -0
- package/src/util/nanostores.test.ts +3 -1
- package/src/util/ssr.ts +1 -0
- package/tsconfig.json +1 -1
- package/tsdown.config.ts +1 -0
- package/vitest.config.ts +2 -1
|
@@ -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", () => {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
281
|
+
test("ifMatchesRoute - supports internal routes", async () => {
|
|
280
282
|
const config = {};
|
|
281
283
|
|
|
282
|
-
const
|
|
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
|
-
.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/api/route.test.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { test, expect, expectTypeOf, describe } from "vitest";
|
|
2
|
-
|
|
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
|
|
package/src/api/shared-types.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { test, expect, expectTypeOf, describe } from "vitest";
|
|
2
|
+
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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<
|
|
159
|
+
expectTypeOf<NonExistentSchema>().toEqualTypeOf<unknown>();
|
|
156
160
|
|
|
157
161
|
type PathWithNoSchema = ExtractOutputSchemaForPath<typeof _testRoutes, "/home">;
|
|
158
|
-
expectTypeOf<PathWithNoSchema>().toEqualTypeOf<StandardSchemaV1<unknown, unknown
|
|
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",
|