@jay-framework/fullstack-component 0.9.0 → 0.11.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/README.md +371 -2
- package/dist/index.d.ts +469 -16
- package/dist/index.js +381 -11
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Jay Stack automatically generates **phase-specific ViewState types** from your c
|
|
|
47
47
|
|
|
48
48
|
**Example:**
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
```typescript
|
|
51
51
|
// TypeScript automatically knows which properties are valid in each phase
|
|
52
52
|
.withSlowlyRender(async () => {
|
|
53
53
|
return partialRender({
|
|
@@ -61,6 +61,7 @@ Jay Stack automatically generates **phase-specific ViewState types** from your c
|
|
|
61
61
|
productName: 'Widget', // ❌ TypeScript Error: Not in FastViewState
|
|
62
62
|
}, {});
|
|
63
63
|
})
|
|
64
|
+
```
|
|
64
65
|
|
|
65
66
|
### Specifying Phases in Contracts
|
|
66
67
|
|
|
@@ -94,7 +95,7 @@ You can annotate your contract properties with the `phase` attribute to control
|
|
|
94
95
|
</div>
|
|
95
96
|
</body>
|
|
96
97
|
</html>
|
|
97
|
-
|
|
98
|
+
```
|
|
98
99
|
|
|
99
100
|
#### Jay Contract (Headless)
|
|
100
101
|
|
|
@@ -532,6 +533,208 @@ Creates a redirect response.
|
|
|
532
533
|
return redirect3xx(301, 'http://some.domain.com');
|
|
533
534
|
```
|
|
534
535
|
|
|
536
|
+
## RenderPipeline - Functional Composition API
|
|
537
|
+
|
|
538
|
+
The `RenderPipeline` class provides a functional programming approach to building render results with automatic error propagation and type-safe chaining.
|
|
539
|
+
|
|
540
|
+
### Why Use RenderPipeline?
|
|
541
|
+
|
|
542
|
+
Traditional render functions require manual error handling:
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
// Traditional approach - verbose error handling
|
|
546
|
+
async function renderSlowlyChanging(props) {
|
|
547
|
+
try {
|
|
548
|
+
const product = await getProductBySlug(props.slug);
|
|
549
|
+
if (!product) return notFound();
|
|
550
|
+
return partialRender({ name: product.name }, { productId: product.id });
|
|
551
|
+
} catch (error) {
|
|
552
|
+
return serverError5xx(503);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
With `RenderPipeline`, errors propagate automatically and you get clean, chainable code:
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
// RenderPipeline approach - clean and composable
|
|
561
|
+
async function renderSlowlyChanging(props) {
|
|
562
|
+
const Pipeline = RenderPipeline.for<ProductSlowVS, ProductCF>();
|
|
563
|
+
|
|
564
|
+
return Pipeline.try(() => getProductBySlug(props.slug))
|
|
565
|
+
.recover((err) => Pipeline.serverError(503, 'Database unavailable'))
|
|
566
|
+
.map((product) => (product ? product : Pipeline.notFound('Product not found')))
|
|
567
|
+
.toPhaseOutput((product) => ({
|
|
568
|
+
viewState: { name: product.name },
|
|
569
|
+
carryForward: { productId: product.id },
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### Key Features
|
|
575
|
+
|
|
576
|
+
- **Type-safe from start**: Declare target `ViewState` and `CarryForward` types upfront
|
|
577
|
+
- **Unified `map()`**: Handles sync values, async values, and conditional errors
|
|
578
|
+
- **Automatic error propagation**: Errors pass through the chain untouched
|
|
579
|
+
- **Single async point**: Only `toPhaseOutput()` is async - all `map()` calls are sync
|
|
580
|
+
- **Clean error recovery**: Handle errors at any point with `recover()`
|
|
581
|
+
|
|
582
|
+
### Basic Usage
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
import { RenderPipeline } from '@jay-framework/fullstack-component';
|
|
586
|
+
|
|
587
|
+
// 1. Create a typed pipeline factory
|
|
588
|
+
const Pipeline = RenderPipeline.for<MyViewState, MyCarryForward>();
|
|
589
|
+
|
|
590
|
+
// 2. Start the pipeline
|
|
591
|
+
Pipeline.ok(value) // Start with a value
|
|
592
|
+
Pipeline.try(() => fetchData()) // Start with a function (catches errors)
|
|
593
|
+
Pipeline.notFound('Not found') // Start with an error
|
|
594
|
+
|
|
595
|
+
// 3. Transform with map()
|
|
596
|
+
pipeline
|
|
597
|
+
.map(x => x * 2) // Sync transformation
|
|
598
|
+
.map(async x => fetchDetails(x)) // Async transformation
|
|
599
|
+
.map(x => x.valid ? x : Pipeline.notFound()) // Conditional error
|
|
600
|
+
|
|
601
|
+
// 4. Handle errors with recover()
|
|
602
|
+
pipeline.recover(err => Pipeline.ok({ fallback: true }))
|
|
603
|
+
|
|
604
|
+
// 5. Produce final output with toPhaseOutput()
|
|
605
|
+
pipeline.toPhaseOutput(data => ({
|
|
606
|
+
viewState: { ... },
|
|
607
|
+
carryForward: { ... }
|
|
608
|
+
}))
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Complete Example
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
import { RenderPipeline, SlowlyRenderResult } from '@jay-framework/fullstack-component';
|
|
615
|
+
|
|
616
|
+
interface ProductViewState {
|
|
617
|
+
name: string;
|
|
618
|
+
price: number;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
interface ProductCarryForward {
|
|
622
|
+
productId: string;
|
|
623
|
+
inventoryItemId: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function renderSlowlyChanging(
|
|
627
|
+
props: PageProps & { slug: string },
|
|
628
|
+
): Promise<SlowlyRenderResult<ProductViewState, ProductCarryForward>> {
|
|
629
|
+
const Pipeline = RenderPipeline.for<ProductViewState, ProductCarryForward>();
|
|
630
|
+
|
|
631
|
+
return Pipeline.try(() => getProductBySlug(props.slug))
|
|
632
|
+
.recover((err) => Pipeline.serverError(503, 'Database unavailable'))
|
|
633
|
+
.map((product) =>
|
|
634
|
+
product ? product : Pipeline.notFound('Product not found', { slug: props.slug }),
|
|
635
|
+
)
|
|
636
|
+
.map(async (product) => {
|
|
637
|
+
// Chain additional async operations
|
|
638
|
+
const pricing = await getPricing(product.id);
|
|
639
|
+
return { ...product, pricing };
|
|
640
|
+
})
|
|
641
|
+
.toPhaseOutput((data) => ({
|
|
642
|
+
viewState: {
|
|
643
|
+
name: data.name,
|
|
644
|
+
price: data.pricing.amount,
|
|
645
|
+
},
|
|
646
|
+
carryForward: {
|
|
647
|
+
productId: data.id,
|
|
648
|
+
inventoryItemId: data.inventoryItemId,
|
|
649
|
+
},
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### API Reference
|
|
655
|
+
|
|
656
|
+
#### `RenderPipeline.for<ViewState, CarryForward>()`
|
|
657
|
+
|
|
658
|
+
Creates a typed pipeline factory. Returns an object with entry point methods:
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
const Pipeline = RenderPipeline.for<MyViewState, MyCarryForward>();
|
|
662
|
+
|
|
663
|
+
Pipeline.ok(value); // Start with success value
|
|
664
|
+
Pipeline.try(fn); // Start with function (catches errors)
|
|
665
|
+
Pipeline.from(outcome); // Start from existing RenderOutcome
|
|
666
|
+
Pipeline.notFound(msg, details); // Start with 404 error
|
|
667
|
+
Pipeline.badRequest(msg); // Start with 400 error
|
|
668
|
+
Pipeline.unauthorized(msg); // Start with 401 error
|
|
669
|
+
Pipeline.forbidden(msg); // Start with 403 error
|
|
670
|
+
Pipeline.serverError(status, msg); // Start with 5xx error
|
|
671
|
+
Pipeline.clientError(status, msg); // Start with 4xx error
|
|
672
|
+
Pipeline.redirect(status, url); // Start with redirect
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
#### `pipeline.map(fn)`
|
|
676
|
+
|
|
677
|
+
Transforms the working value. Always returns `RenderPipeline` (sync).
|
|
678
|
+
|
|
679
|
+
The function can return:
|
|
680
|
+
|
|
681
|
+
- `U` - Plain value (sync transformation)
|
|
682
|
+
- `Promise<U>` - Async value (resolved at `toPhaseOutput`)
|
|
683
|
+
- `RenderPipeline<U>` - For conditional errors/branching
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
pipeline
|
|
687
|
+
.map((x) => x * 2) // Sync
|
|
688
|
+
.map(async (x) => fetchData(x)) // Async
|
|
689
|
+
.map((x) => (x.valid ? x : Pipeline.notFound())); // Conditional
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
#### `pipeline.recover(fn)`
|
|
693
|
+
|
|
694
|
+
Handles errors, potentially recovering to success:
|
|
695
|
+
|
|
696
|
+
```typescript
|
|
697
|
+
pipeline.recover((error) => {
|
|
698
|
+
console.error('Error:', error.message);
|
|
699
|
+
return Pipeline.ok({ fallback: true });
|
|
700
|
+
});
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
#### `pipeline.toPhaseOutput(fn)`
|
|
704
|
+
|
|
705
|
+
Converts to final `RenderOutcome`. This is the **only async method**.
|
|
706
|
+
|
|
707
|
+
Resolves all pending promises and applies the final mapping to produce `ViewState` and `CarryForward`:
|
|
708
|
+
|
|
709
|
+
```typescript
|
|
710
|
+
await pipeline.toPhaseOutput((data) => ({
|
|
711
|
+
viewState: { name: data.name, value: data.value },
|
|
712
|
+
carryForward: { id: data.id },
|
|
713
|
+
}));
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
#### Utility Methods
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
pipeline.isOk(); // true if success state
|
|
720
|
+
pipeline.isError(); // true if error state
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
### Error Types with Messages
|
|
724
|
+
|
|
725
|
+
All error types now support optional `message`, `code`, and `details`:
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
Pipeline.notFound('Product not found', { slug: 'my-product' });
|
|
729
|
+
Pipeline.serverError(503, 'Database unavailable', { retryAfter: 30 });
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
The error types are:
|
|
733
|
+
|
|
734
|
+
- `ServerError5xx` - Server errors (5xx status codes)
|
|
735
|
+
- `ClientError4xx` - Client errors (4xx status codes)
|
|
736
|
+
- `Redirect3xx` - Redirects (3xx status codes)
|
|
737
|
+
|
|
535
738
|
## Complete Example with Phase Validation
|
|
536
739
|
|
|
537
740
|
Here's a complete example showing how phase annotations in your contract provide compile-time type safety:
|
|
@@ -665,6 +868,172 @@ export const userProfile = makeJayStackComponent<UserProfileContract>()
|
|
|
665
868
|
- ⚡ **Optimal performance**: Framework can cache slow data aggressively
|
|
666
869
|
- 🧹 **No boilerplate**: No manual type annotations needed in render functions
|
|
667
870
|
|
|
871
|
+
## Server Actions
|
|
872
|
+
|
|
873
|
+
Server actions enable client-side code to call server-side functions after the initial page load. They provide type-safe RPC communication between interactive components and the server.
|
|
874
|
+
|
|
875
|
+
### Action Builder API
|
|
876
|
+
|
|
877
|
+
```typescript
|
|
878
|
+
import { makeJayAction, makeJayQuery, ActionError } from '@jay-framework/fullstack-component';
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
| Builder | Default Method | Use Case |
|
|
882
|
+
| --------------- | -------------- | --------------------------------------------------- |
|
|
883
|
+
| `makeJayAction` | POST | Mutations: add to cart, submit form, update profile |
|
|
884
|
+
| `makeJayQuery` | GET | Reads: search, get details, list items (cacheable) |
|
|
885
|
+
|
|
886
|
+
### Defining Actions
|
|
887
|
+
|
|
888
|
+
```typescript
|
|
889
|
+
// src/actions/cart.actions.ts
|
|
890
|
+
import { makeJayAction, ActionError } from '@jay-framework/fullstack-component';
|
|
891
|
+
import { CART_SERVICE, INVENTORY_SERVICE } from '../services';
|
|
892
|
+
|
|
893
|
+
export const addToCart = makeJayAction('cart.addToCart')
|
|
894
|
+
.withServices(CART_SERVICE, INVENTORY_SERVICE)
|
|
895
|
+
.withHandler(async (input: { productId: string; quantity: number }, cartService, inventory) => {
|
|
896
|
+
const available = await inventory.getAvailableUnits(input.productId);
|
|
897
|
+
if (available < input.quantity) {
|
|
898
|
+
throw new ActionError('NOT_AVAILABLE', `Only ${available} units available`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const cart = await cartService.addItem(input.productId, input.quantity);
|
|
902
|
+
return { cartItemCount: cart.items.length };
|
|
903
|
+
});
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
### Defining Queries (GET with Caching)
|
|
907
|
+
|
|
908
|
+
```typescript
|
|
909
|
+
// src/actions/search.actions.ts
|
|
910
|
+
import { makeJayQuery } from '@jay-framework/fullstack-component';
|
|
911
|
+
import { PRODUCTS_DATABASE_SERVICE } from '../services';
|
|
912
|
+
|
|
913
|
+
export const searchProducts = makeJayQuery('products.search')
|
|
914
|
+
.withServices(PRODUCTS_DATABASE_SERVICE)
|
|
915
|
+
.withCaching({ maxAge: 60, staleWhileRevalidate: 120 })
|
|
916
|
+
.withHandler(async (input: { query: string; page?: number }, productsDb) => {
|
|
917
|
+
const results = await productsDb.search(input.query, {
|
|
918
|
+
page: input.page ?? 1,
|
|
919
|
+
limit: 20,
|
|
920
|
+
});
|
|
921
|
+
return {
|
|
922
|
+
products: results.items,
|
|
923
|
+
totalCount: results.total,
|
|
924
|
+
hasMore: results.hasMore,
|
|
925
|
+
};
|
|
926
|
+
});
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
### Builder Methods
|
|
930
|
+
|
|
931
|
+
#### `.withServices(...serviceMarkers)`
|
|
932
|
+
|
|
933
|
+
Inject server-side services (same pattern as `makeJayStackComponent`):
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
export const addToCart = makeJayAction('cart.addToCart')
|
|
937
|
+
.withServices(CART_SERVICE, INVENTORY_SERVICE)
|
|
938
|
+
.withHandler(async (input, cartService, inventory) => {
|
|
939
|
+
// Services are injected after input
|
|
940
|
+
});
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
#### `.withMethod(method)`
|
|
944
|
+
|
|
945
|
+
Override the default HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`):
|
|
946
|
+
|
|
947
|
+
```typescript
|
|
948
|
+
export const deleteProduct = makeJayAction('products.delete')
|
|
949
|
+
.withMethod('DELETE')
|
|
950
|
+
.withHandler(async (input: { id: string }) => {
|
|
951
|
+
/* ... */
|
|
952
|
+
});
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
#### `.withCaching(options)`
|
|
956
|
+
|
|
957
|
+
Enable caching for GET requests:
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
export const getCategories = makeJayQuery('products.categories')
|
|
961
|
+
.withCaching({ maxAge: 300, staleWhileRevalidate: 600 })
|
|
962
|
+
.withHandler(async () => {
|
|
963
|
+
/* ... */
|
|
964
|
+
});
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
### Error Handling
|
|
968
|
+
|
|
969
|
+
Use `ActionError` for business logic errors (returns 422 status):
|
|
970
|
+
|
|
971
|
+
```typescript
|
|
972
|
+
import { ActionError } from '@jay-framework/fullstack-component';
|
|
973
|
+
|
|
974
|
+
export const addToCart = makeJayAction('cart.addToCart').withHandler(
|
|
975
|
+
async (input, cartService, inventory) => {
|
|
976
|
+
const available = await inventory.check(input.productId);
|
|
977
|
+
|
|
978
|
+
if (available < input.quantity) {
|
|
979
|
+
throw new ActionError('NOT_AVAILABLE', 'Product is out of stock');
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return { success: true };
|
|
983
|
+
},
|
|
984
|
+
);
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### Client Usage
|
|
988
|
+
|
|
989
|
+
Actions are imported and called like regular async functions:
|
|
990
|
+
|
|
991
|
+
```typescript
|
|
992
|
+
// pages/products/[slug]/page.ts
|
|
993
|
+
import { addToCart } from '../../../actions/cart.actions';
|
|
994
|
+
import { ActionError } from '@jay-framework/fullstack-component';
|
|
995
|
+
|
|
996
|
+
function ProductPageInteractive(props, refs, viewState, carryForward) {
|
|
997
|
+
refs.addToCart.onclick(async () => {
|
|
998
|
+
try {
|
|
999
|
+
const result = await addToCart({
|
|
1000
|
+
productId: carryForward.productId,
|
|
1001
|
+
quantity: 1,
|
|
1002
|
+
});
|
|
1003
|
+
// Success! result.cartItemCount is available
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
if (e instanceof ActionError) {
|
|
1006
|
+
showNotification(e.message); // "Product is out of stock"
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
return { render: () => ({}) };
|
|
1012
|
+
}
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Auto-Registration
|
|
1016
|
+
|
|
1017
|
+
Actions in `src/actions/*.actions.ts` are automatically discovered and registered on server startup. No manual registration needed.
|
|
1018
|
+
|
|
1019
|
+
### Type Inference
|
|
1020
|
+
|
|
1021
|
+
Input and output types are inferred from the handler function:
|
|
1022
|
+
|
|
1023
|
+
```typescript
|
|
1024
|
+
export const addToCart = makeJayAction('cart.addToCart').withHandler(
|
|
1025
|
+
async (
|
|
1026
|
+
input: { productId: string; quantity: number }, // ← Input type
|
|
1027
|
+
) => {
|
|
1028
|
+
return { cartItemCount: 5 }; // ← Output type
|
|
1029
|
+
},
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
// Client gets full type safety
|
|
1033
|
+
const result = await addToCart({ productId: '123', quantity: 1 });
|
|
1034
|
+
// ^? { cartItemCount: number }
|
|
1035
|
+
```
|
|
1036
|
+
|
|
668
1037
|
## Advanced Examples
|
|
669
1038
|
|
|
670
1039
|
### A Product Page with URL Parameters
|