@fragno-dev/core 0.1.10 → 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 +139 -131
- package/CHANGELOG.md +63 -0
- package/dist/api/api.d.ts +23 -5
- 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 +129 -32
- package/dist/api/fragment-instantiator.d.ts.map +1 -1
- package/dist/api/fragment-instantiator.js +232 -50
- 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 +5 -27
- package/dist/mod-client.d.ts.map +1 -1
- package/dist/mod-client.js +50 -13
- package/dist/mod-client.js.map +1 -1
- package/dist/mod.d.ts +4 -3
- 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 +31 -18
- package/src/api/api.ts +24 -0
- package/src/api/fragment-definition-builder.ts +36 -17
- package/src/api/fragment-instantiator.test.ts +429 -1
- package/src/api/fragment-instantiator.ts +572 -58
- 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 +89 -9
- package/src/mod.ts +7 -1
- package/src/runtime.ts +48 -0
- package/src/test/test.ts +13 -4
- package/tsdown.config.ts +1 -0
|
@@ -24,12 +24,12 @@ export type LinkedFragmentCallback<
|
|
|
24
24
|
TConfig,
|
|
25
25
|
TOptions extends FragnoPublicConfig,
|
|
26
26
|
TServiceDependencies,
|
|
27
|
+
TFragment extends AnyFragnoInstantiatedFragment = AnyFragnoInstantiatedFragment,
|
|
27
28
|
> = (context: {
|
|
28
29
|
config: TConfig;
|
|
29
30
|
options: TOptions;
|
|
30
31
|
serviceDependencies?: TServiceDependencies;
|
|
31
|
-
|
|
32
|
-
}) => FragnoInstantiatedFragment<any, any, any, any, any, any, any>;
|
|
32
|
+
}) => TFragment;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Extract the services type from a FragnoInstantiatedFragment
|
|
@@ -223,12 +223,27 @@ export interface FragmentDefinition<
|
|
|
223
223
|
deps: TDeps;
|
|
224
224
|
}) => RequestContextStorage<TRequestStorage>;
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Optional factory for internal data attached to fragment.$internal.
|
|
228
|
+
*/
|
|
229
|
+
internalDataFactory?: (context: {
|
|
230
|
+
config: TConfig;
|
|
231
|
+
options: TOptions;
|
|
232
|
+
deps: TDeps;
|
|
233
|
+
linkedFragments: TLinkedFragments;
|
|
234
|
+
}) => Record<string, unknown> | void;
|
|
235
|
+
|
|
226
236
|
/**
|
|
227
237
|
* Optional linked fragments that will be automatically instantiated with this fragment.
|
|
228
238
|
* Linked fragments are service-only and share the same config/options as the parent.
|
|
229
239
|
*/
|
|
230
240
|
linkedFragments?: {
|
|
231
|
-
[K in keyof TLinkedFragments]: LinkedFragmentCallback<
|
|
241
|
+
[K in keyof TLinkedFragments]: LinkedFragmentCallback<
|
|
242
|
+
TConfig,
|
|
243
|
+
TOptions,
|
|
244
|
+
TServiceDependencies,
|
|
245
|
+
TLinkedFragments[K]
|
|
246
|
+
>;
|
|
232
247
|
};
|
|
233
248
|
|
|
234
249
|
$serviceThisContext?: TServiceThisContext;
|
|
@@ -307,7 +322,12 @@ export class FragmentDefinitionBuilder<
|
|
|
307
322
|
deps: TDeps;
|
|
308
323
|
}) => RequestContextStorage<TRequestStorage>;
|
|
309
324
|
#linkedFragments?: {
|
|
310
|
-
[K in keyof TLinkedFragments]: LinkedFragmentCallback<
|
|
325
|
+
[K in keyof TLinkedFragments]: LinkedFragmentCallback<
|
|
326
|
+
TConfig,
|
|
327
|
+
TOptions,
|
|
328
|
+
TServiceDependencies,
|
|
329
|
+
TLinkedFragments[K]
|
|
330
|
+
>;
|
|
311
331
|
};
|
|
312
332
|
|
|
313
333
|
constructor(
|
|
@@ -368,7 +388,8 @@ export class FragmentDefinitionBuilder<
|
|
|
368
388
|
[K in keyof TLinkedFragments]: LinkedFragmentCallback<
|
|
369
389
|
TConfig,
|
|
370
390
|
TOptions,
|
|
371
|
-
TServiceDependencies
|
|
391
|
+
TServiceDependencies,
|
|
392
|
+
TLinkedFragments[K]
|
|
372
393
|
>;
|
|
373
394
|
};
|
|
374
395
|
},
|
|
@@ -966,15 +987,13 @@ export class FragmentDefinitionBuilder<
|
|
|
966
987
|
|
|
967
988
|
/**
|
|
968
989
|
* Register a linked fragment that will be automatically instantiated.
|
|
969
|
-
* Linked fragments
|
|
970
|
-
*
|
|
990
|
+
* Linked fragments share the same config/options as the parent and their services
|
|
991
|
+
* are exposed as private services. Routes are not exposed by default, but the
|
|
992
|
+
* instantiator may mount internal linked fragment routes under an internal prefix.
|
|
971
993
|
*/
|
|
972
|
-
withLinkedFragment<
|
|
973
|
-
const TName extends string,
|
|
974
|
-
TCallback extends LinkedFragmentCallback<TConfig, TOptions, TServiceDependencies>,
|
|
975
|
-
>(
|
|
994
|
+
withLinkedFragment<const TName extends string, TFragment extends AnyFragnoInstantiatedFragment>(
|
|
976
995
|
name: TName,
|
|
977
|
-
callback:
|
|
996
|
+
callback: LinkedFragmentCallback<TConfig, TOptions, TServiceDependencies, TFragment>,
|
|
978
997
|
): FragmentDefinitionBuilder<
|
|
979
998
|
TConfig,
|
|
980
999
|
TOptions,
|
|
@@ -982,18 +1001,18 @@ export class FragmentDefinitionBuilder<
|
|
|
982
1001
|
TBaseServices,
|
|
983
1002
|
TServices,
|
|
984
1003
|
TServiceDependencies,
|
|
985
|
-
TPrivateServices & ExtractLinkedServices<
|
|
1004
|
+
TPrivateServices & ExtractLinkedServices<() => TFragment>,
|
|
986
1005
|
TServiceThisContext,
|
|
987
1006
|
THandlerThisContext,
|
|
988
1007
|
TRequestStorage,
|
|
989
|
-
TLinkedFragments & { [K in TName]:
|
|
1008
|
+
TLinkedFragments & { [K in TName]: TFragment }
|
|
990
1009
|
> {
|
|
991
1010
|
const newLinkedFragments = {
|
|
992
1011
|
...this.#linkedFragments,
|
|
993
1012
|
[name]: callback,
|
|
994
1013
|
};
|
|
995
1014
|
|
|
996
|
-
// Cast is safe: We're declaring that the returned builder has TPrivateServices & ExtractLinkedServices<
|
|
1015
|
+
// Cast is safe: We're declaring that the returned builder has TPrivateServices & ExtractLinkedServices<TFragment>,
|
|
997
1016
|
// even though the runtime privateServices hasn't changed yet. The linked fragment services will be
|
|
998
1017
|
// merged into privateServices at instantiation time by the instantiator.
|
|
999
1018
|
return new FragmentDefinitionBuilder(this.#name, {
|
|
@@ -1013,11 +1032,11 @@ export class FragmentDefinitionBuilder<
|
|
|
1013
1032
|
TBaseServices,
|
|
1014
1033
|
TServices,
|
|
1015
1034
|
TServiceDependencies,
|
|
1016
|
-
TPrivateServices & ExtractLinkedServices<
|
|
1035
|
+
TPrivateServices & ExtractLinkedServices<() => TFragment>,
|
|
1017
1036
|
TServiceThisContext,
|
|
1018
1037
|
THandlerThisContext,
|
|
1019
1038
|
TRequestStorage,
|
|
1020
|
-
TLinkedFragments & { [K in TName]:
|
|
1039
|
+
TLinkedFragments & { [K in TName]: TFragment }
|
|
1021
1040
|
>;
|
|
1022
1041
|
}
|
|
1023
1042
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi, expectTypeOf } from "vitest";
|
|
2
2
|
import { defineFragment } from "./fragment-definition-builder";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
instantiate,
|
|
5
|
+
instantiateFragment,
|
|
6
|
+
FragnoInstantiatedFragment,
|
|
7
|
+
} from "./fragment-instantiator";
|
|
4
8
|
import { defineRoute, defineRoutes, type AnyFragmentDefinition } from "./route";
|
|
5
9
|
import type { FragnoPublicConfig } from "./shared-types";
|
|
6
10
|
import type { RequestThisContext } from "./api";
|
|
@@ -412,6 +416,56 @@ describe("fragment-instantiator", () => {
|
|
|
412
416
|
expect(data).toEqual({ userId: "123" });
|
|
413
417
|
});
|
|
414
418
|
|
|
419
|
+
it("should URL decode path params", async () => {
|
|
420
|
+
const definition = defineFragment("test-fragment").build();
|
|
421
|
+
|
|
422
|
+
const route = defineRoute({
|
|
423
|
+
method: "GET",
|
|
424
|
+
path: "/users/:name",
|
|
425
|
+
handler: async (input, { json }) => {
|
|
426
|
+
return json({ userName: input.pathParams.name });
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const fragment = instantiate(definition)
|
|
431
|
+
.withRoutes([route])
|
|
432
|
+
.withOptions({ mountRoute: "/api" })
|
|
433
|
+
.build();
|
|
434
|
+
|
|
435
|
+
// URL with encoded space: "a%20b" should be decoded to "a b"
|
|
436
|
+
const request = new Request("http://localhost/api/users/a%20b");
|
|
437
|
+
const response = await fragment.handler(request);
|
|
438
|
+
|
|
439
|
+
expect(response.status).toBe(200);
|
|
440
|
+
const data = await response.json();
|
|
441
|
+
expect(data).toEqual({ userName: "a b" });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("should URL decode path params with special characters", async () => {
|
|
445
|
+
const definition = defineFragment("test-fragment").build();
|
|
446
|
+
|
|
447
|
+
const route = defineRoute({
|
|
448
|
+
method: "GET",
|
|
449
|
+
path: "/files/:path",
|
|
450
|
+
handler: async (input, { json }) => {
|
|
451
|
+
return json({ filePath: input.pathParams.path });
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const fragment = instantiate(definition)
|
|
456
|
+
.withRoutes([route])
|
|
457
|
+
.withOptions({ mountRoute: "/api" })
|
|
458
|
+
.build();
|
|
459
|
+
|
|
460
|
+
// URL with encoded slash: "folder%2Fsubfolder" should be decoded to "folder/subfolder"
|
|
461
|
+
const request = new Request("http://localhost/api/files/folder%2Fsubfolder");
|
|
462
|
+
const response = await fragment.handler(request);
|
|
463
|
+
|
|
464
|
+
expect(response.status).toBe(200);
|
|
465
|
+
const data = await response.json();
|
|
466
|
+
expect(data).toEqual({ filePath: "folder/subfolder" });
|
|
467
|
+
});
|
|
468
|
+
|
|
415
469
|
it("should handle POST requests with body", async () => {
|
|
416
470
|
const definition = defineFragment("test-fragment").build();
|
|
417
471
|
|
|
@@ -446,6 +500,170 @@ describe("fragment-instantiator", () => {
|
|
|
446
500
|
const data = await response.json();
|
|
447
501
|
expect(data).toEqual({ received: { test: "data" } });
|
|
448
502
|
});
|
|
503
|
+
|
|
504
|
+
it("should accept FormData for routes with contentType: multipart/form-data", async () => {
|
|
505
|
+
const definition = defineFragment("test-fragment").build();
|
|
506
|
+
|
|
507
|
+
const route = defineRoute({
|
|
508
|
+
method: "POST",
|
|
509
|
+
path: "/upload",
|
|
510
|
+
contentType: "multipart/form-data",
|
|
511
|
+
handler: async (ctx, { json }) => {
|
|
512
|
+
const formData = ctx.formData();
|
|
513
|
+
const description = formData.get("description") as string;
|
|
514
|
+
return json({ description });
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const fragment = instantiate(definition)
|
|
519
|
+
.withRoutes([route])
|
|
520
|
+
.withOptions({ mountRoute: "/api" })
|
|
521
|
+
.build();
|
|
522
|
+
|
|
523
|
+
const formData = new FormData();
|
|
524
|
+
formData.append("description", "Test file upload");
|
|
525
|
+
|
|
526
|
+
const request = new Request("http://localhost/api/upload", {
|
|
527
|
+
method: "POST",
|
|
528
|
+
body: formData,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const response = await fragment.handler(request);
|
|
532
|
+
|
|
533
|
+
expect(response.status).toBe(200);
|
|
534
|
+
const data = await response.json();
|
|
535
|
+
expect(data).toEqual({ description: "Test file upload" });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("should reject FormData for JSON routes (default contentType)", async () => {
|
|
539
|
+
const definition = defineFragment("test-fragment").build();
|
|
540
|
+
|
|
541
|
+
const route = defineRoute({
|
|
542
|
+
method: "POST",
|
|
543
|
+
path: "/json-only",
|
|
544
|
+
inputSchema: z.object({ name: z.string() }),
|
|
545
|
+
handler: async ({ input }, { json }) => {
|
|
546
|
+
const body = await input.valid();
|
|
547
|
+
return json(body);
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const fragment = instantiate(definition)
|
|
552
|
+
.withRoutes([route])
|
|
553
|
+
.withOptions({ mountRoute: "/api" })
|
|
554
|
+
.build();
|
|
555
|
+
|
|
556
|
+
const formData = new FormData();
|
|
557
|
+
formData.append("name", "test");
|
|
558
|
+
|
|
559
|
+
const request = new Request("http://localhost/api/json-only", {
|
|
560
|
+
method: "POST",
|
|
561
|
+
body: formData,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const response = await fragment.handler(request);
|
|
565
|
+
|
|
566
|
+
expect(response.status).toBe(415);
|
|
567
|
+
const data = await response.json();
|
|
568
|
+
expect(data.code).toBe("UNSUPPORTED_MEDIA_TYPE");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("should reject JSON for FormData routes", async () => {
|
|
572
|
+
const definition = defineFragment("test-fragment").build();
|
|
573
|
+
|
|
574
|
+
const route = defineRoute({
|
|
575
|
+
method: "POST",
|
|
576
|
+
path: "/upload",
|
|
577
|
+
contentType: "multipart/form-data",
|
|
578
|
+
handler: async (ctx, { json }) => {
|
|
579
|
+
// Verify formData() works (would throw if not FormData)
|
|
580
|
+
ctx.formData();
|
|
581
|
+
return json({ success: true });
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const fragment = instantiate(definition)
|
|
586
|
+
.withRoutes([route])
|
|
587
|
+
.withOptions({ mountRoute: "/api" })
|
|
588
|
+
.build();
|
|
589
|
+
|
|
590
|
+
const request = new Request("http://localhost/api/upload", {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { "Content-Type": "application/json" },
|
|
593
|
+
body: JSON.stringify({ name: "test" }),
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const response = await fragment.handler(request);
|
|
597
|
+
|
|
598
|
+
expect(response.status).toBe(415);
|
|
599
|
+
const data = await response.json();
|
|
600
|
+
expect(data.code).toBe("UNSUPPORTED_MEDIA_TYPE");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("should accept octet-stream for routes with contentType: application/octet-stream", async () => {
|
|
604
|
+
const definition = defineFragment("test-fragment").build();
|
|
605
|
+
|
|
606
|
+
const route = defineRoute({
|
|
607
|
+
method: "PUT",
|
|
608
|
+
path: "/stream",
|
|
609
|
+
contentType: "application/octet-stream",
|
|
610
|
+
handler: async (ctx, { json }) => {
|
|
611
|
+
const stream = ctx.bodyStream();
|
|
612
|
+
const text = await new Response(stream).text();
|
|
613
|
+
return json({ received: text });
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const fragment = instantiate(definition)
|
|
618
|
+
.withRoutes([route])
|
|
619
|
+
.withOptions({ mountRoute: "/api" })
|
|
620
|
+
.build();
|
|
621
|
+
|
|
622
|
+
const request = new Request("http://localhost/api/stream", {
|
|
623
|
+
method: "PUT",
|
|
624
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
625
|
+
body: new TextEncoder().encode("hello"),
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const response = await fragment.handler(request);
|
|
629
|
+
|
|
630
|
+
expect(response.status).toBe(200);
|
|
631
|
+
const data = await response.json();
|
|
632
|
+
expect(data).toEqual({ received: "hello" });
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("should reject JSON for octet-stream routes", async () => {
|
|
636
|
+
const definition = defineFragment("test-fragment").build();
|
|
637
|
+
|
|
638
|
+
const route = defineRoute({
|
|
639
|
+
method: "PUT",
|
|
640
|
+
path: "/stream",
|
|
641
|
+
contentType: "application/octet-stream",
|
|
642
|
+
handler: async (ctx, { json }) => {
|
|
643
|
+
if (!ctx.isBodyStream()) {
|
|
644
|
+
throw new Error("Expected stream body");
|
|
645
|
+
}
|
|
646
|
+
return json({ ok: true });
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const fragment = instantiate(definition)
|
|
651
|
+
.withRoutes([route])
|
|
652
|
+
.withOptions({ mountRoute: "/api" })
|
|
653
|
+
.build();
|
|
654
|
+
|
|
655
|
+
const request = new Request("http://localhost/api/stream", {
|
|
656
|
+
method: "PUT",
|
|
657
|
+
headers: { "Content-Type": "application/json" },
|
|
658
|
+
body: JSON.stringify({ hello: "world" }),
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const response = await fragment.handler(request);
|
|
662
|
+
|
|
663
|
+
expect(response.status).toBe(415);
|
|
664
|
+
const data = await response.json();
|
|
665
|
+
expect(data.code).toBe("UNSUPPORTED_MEDIA_TYPE");
|
|
666
|
+
});
|
|
449
667
|
});
|
|
450
668
|
|
|
451
669
|
describe("callRoute", () => {
|
|
@@ -1281,6 +1499,78 @@ describe("fragment-instantiator", () => {
|
|
|
1281
1499
|
expect(linkedFragment?.name).toBe("linked-fragment");
|
|
1282
1500
|
});
|
|
1283
1501
|
|
|
1502
|
+
it("should mount internal linked fragment routes under /_internal", async () => {
|
|
1503
|
+
const linkedFragmentDef = defineFragment("linked-fragment").build();
|
|
1504
|
+
const linkedRoutes = defineRoutes(linkedFragmentDef).create(({ defineRoute }) => [
|
|
1505
|
+
defineRoute({
|
|
1506
|
+
method: "GET",
|
|
1507
|
+
path: "/status",
|
|
1508
|
+
handler: async (_input, { json }) => {
|
|
1509
|
+
return json({ ok: true });
|
|
1510
|
+
},
|
|
1511
|
+
}),
|
|
1512
|
+
]);
|
|
1513
|
+
|
|
1514
|
+
const definition = defineFragment("main-fragment")
|
|
1515
|
+
.withLinkedFragment("_fragno_internal", ({ config, options }) => {
|
|
1516
|
+
return instantiate(linkedFragmentDef)
|
|
1517
|
+
.withConfig(config)
|
|
1518
|
+
.withOptions(options)
|
|
1519
|
+
.withRoutes([linkedRoutes])
|
|
1520
|
+
.build();
|
|
1521
|
+
})
|
|
1522
|
+
.build();
|
|
1523
|
+
|
|
1524
|
+
const fragment = instantiate(definition).withOptions({}).build();
|
|
1525
|
+
|
|
1526
|
+
const response = await fragment.callRouteRaw("GET", "/_internal/status" as never);
|
|
1527
|
+
expect(response.status).toBe(200);
|
|
1528
|
+
await expect(response.json()).resolves.toEqual({ ok: true });
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("should run middleware set on the internal fragment", async () => {
|
|
1532
|
+
const linkedFragmentDef = defineFragment("linked-fragment").build();
|
|
1533
|
+
const linkedRoutes = defineRoutes(linkedFragmentDef).create(({ defineRoute }) => [
|
|
1534
|
+
defineRoute({
|
|
1535
|
+
method: "GET",
|
|
1536
|
+
path: "/status",
|
|
1537
|
+
handler: async (_input, { json }) => {
|
|
1538
|
+
return json({ ok: true });
|
|
1539
|
+
},
|
|
1540
|
+
}),
|
|
1541
|
+
]);
|
|
1542
|
+
|
|
1543
|
+
const definition = defineFragment("main-fragment")
|
|
1544
|
+
.withLinkedFragment("_fragno_internal", ({ config, options }) => {
|
|
1545
|
+
return instantiate(linkedFragmentDef)
|
|
1546
|
+
.withConfig(config)
|
|
1547
|
+
.withOptions(options)
|
|
1548
|
+
.withRoutes([linkedRoutes])
|
|
1549
|
+
.build();
|
|
1550
|
+
})
|
|
1551
|
+
.build();
|
|
1552
|
+
|
|
1553
|
+
const fragment = instantiate(definition).withOptions({ mountRoute: "/api" }).build();
|
|
1554
|
+
|
|
1555
|
+
const internalFragment = fragment.$internal.linkedFragments._fragno_internal;
|
|
1556
|
+
internalFragment.withMiddleware(async ({ ifMatchesRoute }) => {
|
|
1557
|
+
const result = await ifMatchesRoute("GET", "/status", async (_input, { json }) => {
|
|
1558
|
+
return json({ ok: false, source: "internal-middleware" }, 418);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
return result;
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
const response = await fragment.handler(
|
|
1565
|
+
new Request("http://localhost/api/_internal/status", {
|
|
1566
|
+
method: "GET",
|
|
1567
|
+
}),
|
|
1568
|
+
);
|
|
1569
|
+
|
|
1570
|
+
expect(response.status).toBe(418);
|
|
1571
|
+
expect(await response.json()).toEqual({ ok: false, source: "internal-middleware" });
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1284
1574
|
it("should pass config and options to linked fragments", () => {
|
|
1285
1575
|
interface Config {
|
|
1286
1576
|
value: string;
|
|
@@ -1485,4 +1775,142 @@ describe("fragment-instantiator", () => {
|
|
|
1485
1775
|
expect(data.code).toBe("INTERNAL_SERVER_ERROR");
|
|
1486
1776
|
});
|
|
1487
1777
|
});
|
|
1778
|
+
|
|
1779
|
+
describe("dry run mode", () => {
|
|
1780
|
+
it("should handle dependency errors in dry run mode", () => {
|
|
1781
|
+
interface Config {
|
|
1782
|
+
requiredKey: string;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
const definition = defineFragment<Config>("test-fragment")
|
|
1786
|
+
.withDependencies(({ config }) => {
|
|
1787
|
+
if (!config.requiredKey) {
|
|
1788
|
+
throw new Error("Missing required key");
|
|
1789
|
+
}
|
|
1790
|
+
return { key: config.requiredKey };
|
|
1791
|
+
})
|
|
1792
|
+
.build();
|
|
1793
|
+
|
|
1794
|
+
// Without dry run - should throw
|
|
1795
|
+
expect(() => {
|
|
1796
|
+
instantiateFragment(definition, {} as Config, [], {});
|
|
1797
|
+
}).toThrow("Missing required key");
|
|
1798
|
+
|
|
1799
|
+
// With dry run - should succeed with stub deps
|
|
1800
|
+
const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
|
|
1801
|
+
dryRun: true,
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
|
|
1805
|
+
expect(fragment.$internal.deps).toEqual({});
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
it("should handle service errors in dry run mode", () => {
|
|
1809
|
+
interface Config {
|
|
1810
|
+
value: string;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const definition = defineFragment<Config>("test-fragment")
|
|
1814
|
+
.withDependencies(({ config }) => {
|
|
1815
|
+
if (!config.value) {
|
|
1816
|
+
throw new Error("Missing value");
|
|
1817
|
+
}
|
|
1818
|
+
return { val: config.value };
|
|
1819
|
+
})
|
|
1820
|
+
.providesBaseService(({ deps }) => {
|
|
1821
|
+
if (!deps.val) {
|
|
1822
|
+
throw new Error("Missing deps.val");
|
|
1823
|
+
}
|
|
1824
|
+
return {
|
|
1825
|
+
getValue: () => deps.val,
|
|
1826
|
+
};
|
|
1827
|
+
})
|
|
1828
|
+
.build();
|
|
1829
|
+
|
|
1830
|
+
// Without dry run - should throw during deps initialization
|
|
1831
|
+
expect(() => {
|
|
1832
|
+
instantiateFragment(definition, {} as Config, [], {});
|
|
1833
|
+
}).toThrow("Missing value");
|
|
1834
|
+
|
|
1835
|
+
// With dry run - should succeed with stub services
|
|
1836
|
+
const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
|
|
1837
|
+
dryRun: true,
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
|
|
1841
|
+
// Services should be empty objects in dry run
|
|
1842
|
+
expect(fragment.services).toBeDefined();
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
it("should handle named service errors in dry run mode", () => {
|
|
1846
|
+
interface Config {
|
|
1847
|
+
apiKey: string;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const definition = defineFragment<Config>("test-fragment")
|
|
1851
|
+
.withDependencies(({ config }) => {
|
|
1852
|
+
if (!config.apiKey) {
|
|
1853
|
+
throw new Error("Missing API key");
|
|
1854
|
+
}
|
|
1855
|
+
return { key: config.apiKey };
|
|
1856
|
+
})
|
|
1857
|
+
.providesService("apiService", ({ deps }) => {
|
|
1858
|
+
if (!deps.key) {
|
|
1859
|
+
throw new Error("Cannot create service without key");
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
call: () => `Calling with ${deps.key}`,
|
|
1863
|
+
};
|
|
1864
|
+
})
|
|
1865
|
+
.build();
|
|
1866
|
+
|
|
1867
|
+
const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
|
|
1868
|
+
dryRun: true,
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
|
|
1872
|
+
expect(fragment.services.apiService).toBeDefined();
|
|
1873
|
+
expect(fragment.services.apiService).toEqual({});
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
it("should handle private service errors in dry run mode", () => {
|
|
1877
|
+
interface Config {
|
|
1878
|
+
secret: string;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const definition = defineFragment<Config>("test-fragment")
|
|
1882
|
+
.withDependencies(({ config }) => {
|
|
1883
|
+
if (!config.secret) {
|
|
1884
|
+
throw new Error("Missing secret");
|
|
1885
|
+
}
|
|
1886
|
+
return { secret: config.secret };
|
|
1887
|
+
})
|
|
1888
|
+
.build();
|
|
1889
|
+
|
|
1890
|
+
const fragment = instantiateFragment(definition, {} as Config, [], {}, undefined, {
|
|
1891
|
+
dryRun: true,
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
expect(fragment).toBeInstanceOf(FragnoInstantiatedFragment);
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
it("should not catch errors when dry run is disabled", () => {
|
|
1898
|
+
interface Config {
|
|
1899
|
+
key: string;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
const definition = defineFragment<Config>("test-fragment")
|
|
1903
|
+
.withDependencies(({ config }) => {
|
|
1904
|
+
if (!config.key) {
|
|
1905
|
+
throw new Error("Missing key");
|
|
1906
|
+
}
|
|
1907
|
+
return { key: config.key };
|
|
1908
|
+
})
|
|
1909
|
+
.build();
|
|
1910
|
+
|
|
1911
|
+
expect(() => {
|
|
1912
|
+
instantiateFragment(definition, {} as Config, [], {}, undefined, { dryRun: false });
|
|
1913
|
+
}).toThrow("Missing key");
|
|
1914
|
+
});
|
|
1915
|
+
});
|
|
1488
1916
|
});
|