@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
|
@@ -292,6 +292,29 @@ describe("RequestContext", () => {
|
|
|
292
292
|
);
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
test("Should throw error when trying to validate ReadableStream", async () => {
|
|
296
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
297
|
+
start(controller) {
|
|
298
|
+
controller.enqueue(new TextEncoder().encode("stream body"));
|
|
299
|
+
controller.close();
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const ctx = new RequestInputContext({
|
|
304
|
+
path: "/test",
|
|
305
|
+
pathParams: {},
|
|
306
|
+
searchParams: new URLSearchParams(),
|
|
307
|
+
headers: new Headers(),
|
|
308
|
+
parsedBody: stream,
|
|
309
|
+
inputSchema: validStringSchema,
|
|
310
|
+
method: "POST",
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await expect(ctx.input.valid()).rejects.toThrow(
|
|
314
|
+
"Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
295
318
|
test("Should handle null body", async () => {
|
|
296
319
|
const ctx = new RequestInputContext({
|
|
297
320
|
path: "/test",
|
|
@@ -493,4 +516,133 @@ describe("RequestContext", () => {
|
|
|
493
516
|
expect(ctx.query.get("ssr")).toBe("true");
|
|
494
517
|
});
|
|
495
518
|
});
|
|
519
|
+
|
|
520
|
+
describe("FormData handling", () => {
|
|
521
|
+
test("formData() should return FormData when body is FormData", () => {
|
|
522
|
+
const formData = new FormData();
|
|
523
|
+
formData.append("file", new Blob(["test"]), "test.txt");
|
|
524
|
+
formData.append("description", "A test file");
|
|
525
|
+
|
|
526
|
+
const ctx = new RequestInputContext({
|
|
527
|
+
path: "/upload",
|
|
528
|
+
pathParams: {},
|
|
529
|
+
searchParams: new URLSearchParams(),
|
|
530
|
+
headers: new Headers(),
|
|
531
|
+
parsedBody: formData,
|
|
532
|
+
method: "POST",
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const result = ctx.formData();
|
|
536
|
+
expect(result).toBe(formData);
|
|
537
|
+
expect(result.get("description")).toBe("A test file");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("formData() should throw when body is not FormData", () => {
|
|
541
|
+
const ctx = new RequestInputContext({
|
|
542
|
+
path: "/upload",
|
|
543
|
+
pathParams: {},
|
|
544
|
+
searchParams: new URLSearchParams(),
|
|
545
|
+
headers: new Headers(),
|
|
546
|
+
parsedBody: { key: "value" },
|
|
547
|
+
method: "POST",
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(() => ctx.formData()).toThrow(
|
|
551
|
+
"Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("formData() should throw when body is undefined", () => {
|
|
556
|
+
const ctx = new RequestInputContext({
|
|
557
|
+
path: "/upload",
|
|
558
|
+
pathParams: {},
|
|
559
|
+
searchParams: new URLSearchParams(),
|
|
560
|
+
headers: new Headers(),
|
|
561
|
+
parsedBody: undefined,
|
|
562
|
+
method: "POST",
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(() => ctx.formData()).toThrow(
|
|
566
|
+
"Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("isFormData() should return true when body is FormData", () => {
|
|
571
|
+
const formData = new FormData();
|
|
572
|
+
formData.append("key", "value");
|
|
573
|
+
|
|
574
|
+
const ctx = new RequestInputContext({
|
|
575
|
+
path: "/upload",
|
|
576
|
+
pathParams: {},
|
|
577
|
+
searchParams: new URLSearchParams(),
|
|
578
|
+
headers: new Headers(),
|
|
579
|
+
parsedBody: formData,
|
|
580
|
+
method: "POST",
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
expect(ctx.isFormData()).toBe(true);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("isFormData() should return false when body is JSON", () => {
|
|
587
|
+
const ctx = new RequestInputContext({
|
|
588
|
+
path: "/upload",
|
|
589
|
+
pathParams: {},
|
|
590
|
+
searchParams: new URLSearchParams(),
|
|
591
|
+
headers: new Headers(),
|
|
592
|
+
parsedBody: { key: "value" },
|
|
593
|
+
method: "POST",
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
expect(ctx.isFormData()).toBe(false);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("isFormData() should return false when body is undefined", () => {
|
|
600
|
+
const ctx = new RequestInputContext({
|
|
601
|
+
path: "/upload",
|
|
602
|
+
pathParams: {},
|
|
603
|
+
searchParams: new URLSearchParams(),
|
|
604
|
+
headers: new Headers(),
|
|
605
|
+
parsedBody: undefined,
|
|
606
|
+
method: "POST",
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(ctx.isFormData()).toBe(false);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("isFormData() should return false when body is Blob", () => {
|
|
613
|
+
const blob = new Blob(["test content"], { type: "text/plain" });
|
|
614
|
+
|
|
615
|
+
const ctx = new RequestInputContext({
|
|
616
|
+
path: "/upload",
|
|
617
|
+
pathParams: {},
|
|
618
|
+
searchParams: new URLSearchParams(),
|
|
619
|
+
headers: new Headers(),
|
|
620
|
+
parsedBody: blob,
|
|
621
|
+
method: "POST",
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
expect(ctx.isFormData()).toBe(false);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("Can use isFormData() to conditionally access formData()", () => {
|
|
628
|
+
const formData = new FormData();
|
|
629
|
+
formData.append("file", new Blob(["test"]), "test.txt");
|
|
630
|
+
|
|
631
|
+
const ctx = new RequestInputContext({
|
|
632
|
+
path: "/upload",
|
|
633
|
+
pathParams: {},
|
|
634
|
+
searchParams: new URLSearchParams(),
|
|
635
|
+
headers: new Headers(),
|
|
636
|
+
parsedBody: formData,
|
|
637
|
+
method: "POST",
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
if (ctx.isFormData()) {
|
|
641
|
+
const result = ctx.formData();
|
|
642
|
+
expect(result.get("file")).toBeInstanceOf(Blob);
|
|
643
|
+
} else {
|
|
644
|
+
expect.fail("Should have detected FormData");
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
});
|
|
496
648
|
});
|
|
@@ -7,6 +7,7 @@ export type RequestBodyType =
|
|
|
7
7
|
| unknown // JSON
|
|
8
8
|
| FormData
|
|
9
9
|
| Blob
|
|
10
|
+
| ReadableStream<Uint8Array>
|
|
10
11
|
| null
|
|
11
12
|
| undefined;
|
|
12
13
|
|
|
@@ -153,6 +154,84 @@ export class RequestInputContext<
|
|
|
153
154
|
return this.#body;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Access the request body as FormData.
|
|
159
|
+
*
|
|
160
|
+
* Use this method when handling file uploads or multipart form submissions.
|
|
161
|
+
* The request must have been sent with Content-Type: multipart/form-data.
|
|
162
|
+
*
|
|
163
|
+
* @throws Error if the request body is not FormData
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* defineRoute({
|
|
168
|
+
* method: "POST",
|
|
169
|
+
* path: "/upload",
|
|
170
|
+
* async handler(ctx, res) {
|
|
171
|
+
* const formData = ctx.formData();
|
|
172
|
+
* const file = formData.get("file") as File;
|
|
173
|
+
* const description = formData.get("description") as string;
|
|
174
|
+
* // ... process file
|
|
175
|
+
* }
|
|
176
|
+
* });
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
formData(): FormData {
|
|
180
|
+
if (!(this.#parsedBody instanceof FormData)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return this.#parsedBody;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if the request body is FormData.
|
|
190
|
+
*
|
|
191
|
+
* Useful for routes that accept both JSON and FormData payloads.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* defineRoute({
|
|
196
|
+
* method: "POST",
|
|
197
|
+
* path: "/upload",
|
|
198
|
+
* async handler(ctx, res) {
|
|
199
|
+
* if (ctx.isFormData()) {
|
|
200
|
+
* const formData = ctx.formData();
|
|
201
|
+
* // handle file upload
|
|
202
|
+
* } else {
|
|
203
|
+
* const json = await ctx.input.valid();
|
|
204
|
+
* // handle JSON payload
|
|
205
|
+
* }
|
|
206
|
+
* }
|
|
207
|
+
* });
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
isFormData(): boolean {
|
|
211
|
+
return this.#parsedBody instanceof FormData;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Access the request body as a ReadableStream (application/octet-stream).
|
|
216
|
+
*
|
|
217
|
+
* @throws Error if the request body is not a ReadableStream
|
|
218
|
+
*/
|
|
219
|
+
bodyStream(): ReadableStream<Uint8Array> {
|
|
220
|
+
if (!(this.#parsedBody instanceof ReadableStream)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"Request body is not a ReadableStream. Ensure the request was sent with Content-Type: application/octet-stream.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return this.#parsedBody;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if the request body is a ReadableStream.
|
|
230
|
+
*/
|
|
231
|
+
isBodyStream(): boolean {
|
|
232
|
+
return this.#parsedBody instanceof ReadableStream;
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
/**
|
|
157
236
|
* Input validation context (only if inputSchema is defined)
|
|
158
237
|
* @remarks `InputContext`
|
|
@@ -197,6 +276,12 @@ export class RequestInputContext<
|
|
|
197
276
|
throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
|
|
198
277
|
}
|
|
199
278
|
|
|
279
|
+
if (this.#parsedBody instanceof ReadableStream) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
"Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
200
285
|
const result = await this.#inputSchema["~standard"].validate(this.#parsedBody);
|
|
201
286
|
|
|
202
287
|
if (result.issues) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect, describe, expectTypeOf } from "vitest";
|
|
2
2
|
import { defineFragment } from "./fragment-definition-builder";
|
|
3
3
|
import { instantiate } from "./fragment-instantiator";
|
|
4
|
-
import { defineRoute } from "./route";
|
|
4
|
+
import { defineRoute, defineRoutes } from "./route";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { FragnoApiValidationError } from "./error";
|
|
7
7
|
|
|
@@ -276,6 +276,52 @@ describe("Request Middleware", () => {
|
|
|
276
276
|
expect(middlewareCalled).toBe(true);
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
test("ifMatchesRoute - supports internal linked fragment routes", async () => {
|
|
280
|
+
const config = {};
|
|
281
|
+
|
|
282
|
+
const internalDef = defineFragment("internal").build();
|
|
283
|
+
const internalRoutes = defineRoutes(internalDef).create(({ defineRoute }) => [
|
|
284
|
+
defineRoute({
|
|
285
|
+
method: "GET",
|
|
286
|
+
path: "/status",
|
|
287
|
+
handler: async (_input, { json }) => {
|
|
288
|
+
return json({ ok: true });
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
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
|
+
})
|
|
301
|
+
.build();
|
|
302
|
+
|
|
303
|
+
const instance = instantiate(definition)
|
|
304
|
+
.withConfig(config)
|
|
305
|
+
.withOptions({
|
|
306
|
+
mountRoute: "/api",
|
|
307
|
+
})
|
|
308
|
+
.build()
|
|
309
|
+
.withMiddleware(async ({ ifMatchesRoute }) => {
|
|
310
|
+
return await ifMatchesRoute("GET", "/_internal/status", async ({ path }, { json }) => {
|
|
311
|
+
expectTypeOf(path).toEqualTypeOf<"/_internal/status">();
|
|
312
|
+
return json({ ok: false }, 418);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const req = new Request("http://localhost/api/_internal/status", {
|
|
317
|
+
method: "GET",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const res = await instance.handler(req);
|
|
321
|
+
expect(res.status).toBe(418);
|
|
322
|
+
expect(await res.json()).toEqual({ ok: false });
|
|
323
|
+
});
|
|
324
|
+
|
|
279
325
|
test("only one middleware is supported", async () => {
|
|
280
326
|
const config = {};
|
|
281
327
|
|
|
@@ -113,7 +113,7 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
|
|
|
113
113
|
// Defined as a field so that `this` reference stays in tact when destructuring
|
|
114
114
|
ifMatchesRoute = async <
|
|
115
115
|
const TMethod extends HTTPMethod,
|
|
116
|
-
const TPath extends ExtractRoutePath<TRoutes>,
|
|
116
|
+
const TPath extends ExtractRoutePath<TRoutes, TMethod>,
|
|
117
117
|
const TRoute extends ExtractRouteByPath<TRoutes, TPath, TMethod> = ExtractRouteByPath<
|
|
118
118
|
TRoutes,
|
|
119
119
|
TPath,
|
package/src/api/route.ts
CHANGED
|
@@ -182,18 +182,19 @@ export type AnyFragmentDefinition = FragmentDefinition<
|
|
|
182
182
|
any,
|
|
183
183
|
any,
|
|
184
184
|
any,
|
|
185
|
+
any,
|
|
185
186
|
any
|
|
186
187
|
>;
|
|
187
188
|
|
|
188
189
|
// Extract config from FragmentDefinition
|
|
189
190
|
export type ExtractFragmentConfig<T> =
|
|
190
|
-
T extends FragmentDefinition<infer TConfig, any, any, any, any, any, any, any, any, any>
|
|
191
|
+
T extends FragmentDefinition<infer TConfig, any, any, any, any, any, any, any, any, any, any>
|
|
191
192
|
? TConfig
|
|
192
193
|
: never;
|
|
193
194
|
|
|
194
195
|
// Extract deps from FragmentDefinition
|
|
195
196
|
export type ExtractFragmentDeps<T> =
|
|
196
|
-
T extends FragmentDefinition<any, any, infer TDeps, any, any, any, any, any, any, any>
|
|
197
|
+
T extends FragmentDefinition<any, any, infer TDeps, any, any, any, any, any, any, any, any>
|
|
197
198
|
? TDeps
|
|
198
199
|
: never;
|
|
199
200
|
|
|
@@ -211,6 +212,7 @@ export type ExtractFragmentServices<T> =
|
|
|
211
212
|
any,
|
|
212
213
|
any,
|
|
213
214
|
any,
|
|
215
|
+
any,
|
|
214
216
|
any
|
|
215
217
|
>
|
|
216
218
|
? BoundServices<TBaseServices & TServices>
|
|
@@ -228,6 +230,7 @@ export type ExtractFragmentServiceDeps<T> =
|
|
|
228
230
|
any,
|
|
229
231
|
any,
|
|
230
232
|
any,
|
|
233
|
+
any,
|
|
231
234
|
any
|
|
232
235
|
>
|
|
233
236
|
? TServiceDependencies
|
|
@@ -245,6 +248,7 @@ export type ExtractFragmentServiceThisContext<T> =
|
|
|
245
248
|
any,
|
|
246
249
|
infer TServiceThisContext,
|
|
247
250
|
any,
|
|
251
|
+
any,
|
|
248
252
|
any
|
|
249
253
|
>
|
|
250
254
|
? TServiceThisContext
|
|
@@ -262,6 +266,7 @@ export type ExtractFragmentHandlerThisContext<T> =
|
|
|
262
266
|
any,
|
|
263
267
|
any,
|
|
264
268
|
infer THandlerThisContext,
|
|
269
|
+
any,
|
|
265
270
|
any
|
|
266
271
|
>
|
|
267
272
|
? THandlerThisContext
|
|
@@ -111,6 +111,59 @@ describe("getCacheKey", () => {
|
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
describe("FormData utilities", () => {
|
|
115
|
+
// These tests verify the internal FormData handling behavior
|
|
116
|
+
|
|
117
|
+
test("prepareRequestBody should JSON-stringify regular objects", () => {
|
|
118
|
+
// We can't test internal functions directly, but we can test through the mutator behavior
|
|
119
|
+
// This is a placeholder to document expected behavior
|
|
120
|
+
const body = { name: "test", value: 123 };
|
|
121
|
+
expect(JSON.stringify(body)).toBe('{"name":"test","value":123}');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("FormData should be detected correctly", () => {
|
|
125
|
+
const formData = new FormData();
|
|
126
|
+
formData.append("file", new Blob(["test"]), "test.txt");
|
|
127
|
+
|
|
128
|
+
expect(formData instanceof FormData).toBe(true);
|
|
129
|
+
expect({} instanceof FormData).toBe(false);
|
|
130
|
+
// Note: null instanceof X is a TS error, so we test with a nullable variable
|
|
131
|
+
const nullValue: unknown = null;
|
|
132
|
+
expect(nullValue instanceof FormData).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("File and Blob should be detected correctly", () => {
|
|
136
|
+
const file = new File(["content"], "test.txt", { type: "text/plain" });
|
|
137
|
+
const blob = new Blob(["content"], { type: "text/plain" });
|
|
138
|
+
|
|
139
|
+
expect(file instanceof File).toBe(true);
|
|
140
|
+
expect(file instanceof Blob).toBe(true);
|
|
141
|
+
expect(blob instanceof Blob).toBe(true);
|
|
142
|
+
expect(blob instanceof File).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("toFormData should convert object with files to FormData", () => {
|
|
146
|
+
const file = new File(["content"], "test.txt", { type: "text/plain" });
|
|
147
|
+
const formData = new FormData();
|
|
148
|
+
formData.append("file", file, file.name);
|
|
149
|
+
formData.append("description", "A test file");
|
|
150
|
+
|
|
151
|
+
expect(formData.get("file")).toBeInstanceOf(File);
|
|
152
|
+
expect(formData.get("description")).toBe("A test file");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("FormData can contain multiple files", () => {
|
|
156
|
+
const file1 = new File(["content1"], "test1.txt", { type: "text/plain" });
|
|
157
|
+
const file2 = new File(["content2"], "test2.txt", { type: "text/plain" });
|
|
158
|
+
const formData = new FormData();
|
|
159
|
+
formData.append("files", file1, file1.name);
|
|
160
|
+
formData.append("files", file2, file2.name);
|
|
161
|
+
|
|
162
|
+
const files = formData.getAll("files");
|
|
163
|
+
expect(files).toHaveLength(2);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
114
167
|
describe("invalidation", () => {
|
|
115
168
|
const testFragment = defineFragment("test-fragment").build();
|
|
116
169
|
const testRoutes = [
|
|
@@ -1271,6 +1324,99 @@ describe("createMutator", () => {
|
|
|
1271
1324
|
|
|
1272
1325
|
expect(result).toBeUndefined();
|
|
1273
1326
|
});
|
|
1327
|
+
|
|
1328
|
+
test("should send octet-stream body without wrapping", async () => {
|
|
1329
|
+
const testFragment = defineFragment("test-fragment").build();
|
|
1330
|
+
const testRoutes = [
|
|
1331
|
+
defineRoute({
|
|
1332
|
+
method: "PUT",
|
|
1333
|
+
path: "/upload",
|
|
1334
|
+
contentType: "application/octet-stream",
|
|
1335
|
+
inputSchema: z.unknown(),
|
|
1336
|
+
handler: async (_ctx, { empty }) => empty(),
|
|
1337
|
+
}),
|
|
1338
|
+
] as const;
|
|
1339
|
+
|
|
1340
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1341
|
+
return new Response(null, { status: 204 });
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
1345
|
+
const upload = cb.createMutator("PUT", "/upload");
|
|
1346
|
+
const body = new Uint8Array([1, 2, 3, 4]);
|
|
1347
|
+
|
|
1348
|
+
await upload.mutateQuery({ body });
|
|
1349
|
+
|
|
1350
|
+
const [_url, options] = vi.mocked(global.fetch).mock.calls[0];
|
|
1351
|
+
const headers = options?.headers as Record<string, string> | undefined;
|
|
1352
|
+
|
|
1353
|
+
expect(headers?.["Content-Type"]).toBe("application/octet-stream");
|
|
1354
|
+
expect(options?.body).toBe(body);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
test("should send ReadableStream body with duplex for octet-stream", async () => {
|
|
1358
|
+
const testFragment = defineFragment("test-fragment").build();
|
|
1359
|
+
const testRoutes = [
|
|
1360
|
+
defineRoute({
|
|
1361
|
+
method: "PUT",
|
|
1362
|
+
path: "/upload",
|
|
1363
|
+
contentType: "application/octet-stream",
|
|
1364
|
+
inputSchema: z.unknown(),
|
|
1365
|
+
handler: async (_ctx, { empty }) => empty(),
|
|
1366
|
+
}),
|
|
1367
|
+
] as const;
|
|
1368
|
+
|
|
1369
|
+
let capturedOptions: (RequestInit & { duplex?: "half" }) | undefined;
|
|
1370
|
+
let capturedBodyText = "";
|
|
1371
|
+
|
|
1372
|
+
vi.mocked(global.fetch).mockImplementation(async (_url, options) => {
|
|
1373
|
+
capturedOptions = options as RequestInit & { duplex?: "half" };
|
|
1374
|
+
const body = capturedOptions?.body;
|
|
1375
|
+
|
|
1376
|
+
if (!(body instanceof ReadableStream)) {
|
|
1377
|
+
throw new Error("Expected ReadableStream body");
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const reader = body.getReader();
|
|
1381
|
+
const chunks: Uint8Array[] = [];
|
|
1382
|
+
while (true) {
|
|
1383
|
+
const { done, value } = await reader.read();
|
|
1384
|
+
if (done) {
|
|
1385
|
+
break;
|
|
1386
|
+
}
|
|
1387
|
+
if (value) {
|
|
1388
|
+
chunks.push(value);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1393
|
+
const combined = new Uint8Array(totalLength);
|
|
1394
|
+
let offset = 0;
|
|
1395
|
+
for (const chunk of chunks) {
|
|
1396
|
+
combined.set(chunk, offset);
|
|
1397
|
+
offset += chunk.length;
|
|
1398
|
+
}
|
|
1399
|
+
capturedBodyText = new TextDecoder().decode(combined);
|
|
1400
|
+
|
|
1401
|
+
return new Response(null, { status: 204 });
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
1405
|
+
const upload = cb.createMutator("PUT", "/upload");
|
|
1406
|
+
const body = new ReadableStream<Uint8Array>({
|
|
1407
|
+
start(controller) {
|
|
1408
|
+
controller.enqueue(new TextEncoder().encode("streamed body"));
|
|
1409
|
+
controller.close();
|
|
1410
|
+
},
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
await upload.mutateQuery({ body });
|
|
1414
|
+
|
|
1415
|
+
const headers = capturedOptions?.headers as Record<string, string> | undefined;
|
|
1416
|
+
expect(headers?.["Content-Type"]).toBe("application/octet-stream");
|
|
1417
|
+
expect(capturedOptions?.duplex).toBe("half");
|
|
1418
|
+
expect(capturedBodyText).toBe("streamed body");
|
|
1419
|
+
});
|
|
1274
1420
|
});
|
|
1275
1421
|
|
|
1276
1422
|
describe("createMutator - streaming", () => {
|
|
@@ -1997,6 +2143,55 @@ describe("Custom Fetcher Configuration", () => {
|
|
|
1997
2143
|
expect(url4).toBe("http://localhost:3000/api/test-fragment/users/456?include=posts");
|
|
1998
2144
|
});
|
|
1999
2145
|
|
|
2146
|
+
test("public mountRoute is used for hooks", async () => {
|
|
2147
|
+
let capturedUrl: string | undefined;
|
|
2148
|
+
|
|
2149
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url) => {
|
|
2150
|
+
capturedUrl = String(url);
|
|
2151
|
+
return {
|
|
2152
|
+
headers: new Headers(),
|
|
2153
|
+
ok: true,
|
|
2154
|
+
json: async () => [{ id: 1, name: "John" }],
|
|
2155
|
+
} as Response;
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
const client = createClientBuilder(
|
|
2159
|
+
testFragment,
|
|
2160
|
+
{ ...clientConfig, mountRoute: "/api/uploads-direct" },
|
|
2161
|
+
testRoutes,
|
|
2162
|
+
);
|
|
2163
|
+
|
|
2164
|
+
const useUsers = client.createHook("/users");
|
|
2165
|
+
await useUsers.query();
|
|
2166
|
+
|
|
2167
|
+
expect(capturedUrl).toBe("http://localhost:3000/api/uploads-direct/users");
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
test("public mountRoute is used for mutators", async () => {
|
|
2171
|
+
let capturedUrl: string | undefined;
|
|
2172
|
+
|
|
2173
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url) => {
|
|
2174
|
+
capturedUrl = String(url);
|
|
2175
|
+
return {
|
|
2176
|
+
headers: new Headers(),
|
|
2177
|
+
ok: true,
|
|
2178
|
+
status: 200,
|
|
2179
|
+
json: async () => ({ id: 2, name: "Jane" }),
|
|
2180
|
+
} as Response;
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
const client = createClientBuilder(
|
|
2184
|
+
testFragment,
|
|
2185
|
+
{ ...clientConfig, mountRoute: "/api/uploads-proxy" },
|
|
2186
|
+
testRoutes,
|
|
2187
|
+
);
|
|
2188
|
+
|
|
2189
|
+
const mutator = client.createMutator("POST", "/users");
|
|
2190
|
+
await mutator.mutateQuery({ body: { name: "Jane" } });
|
|
2191
|
+
|
|
2192
|
+
expect(capturedUrl).toBe("http://localhost:3000/api/uploads-proxy/users");
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2000
2195
|
test("getFetcher returns correct fetcher and options", () => {
|
|
2001
2196
|
const customFetch = vi.fn() as unknown as typeof fetch;
|
|
2002
2197
|
const client = createClientBuilder(
|