@jay-framework/fullstack-component 0.8.0 → 0.10.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 +696 -19
- package/dist/index.d.ts +538 -161
- package/dist/index.js +381 -11
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -26,16 +26,118 @@ The `@jay-framework/fullstack-component` package provides a fluent builder API f
|
|
|
26
26
|
|
|
27
27
|
## Rendering Phases
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
- **Fast Rendering**: Use for dynamic data that can be cached
|
|
31
|
-
- **Partial Renders**: Only update the parts of the view state that change
|
|
32
|
-
- **Carry Forward**: Pass data between render phases to avoid recomputation
|
|
29
|
+
Jay Stack components support three rendering phases, each optimized for different data lifecycles:
|
|
33
30
|
|
|
34
|
-
| Rendering Phase
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
| Fast
|
|
38
|
-
| Interactive
|
|
31
|
+
| Rendering Phase | Rendered Where | When Rendered | Use Case |
|
|
32
|
+
| ------------------ | -------------- | ------------------------------ | --------------------------------- |
|
|
33
|
+
| **Slow (Static)** | SSR | Build time or data change time | Product names, descriptions, SKUs |
|
|
34
|
+
| **Fast (Dynamic)** | SSR | Page serving (per request) | Inventory, pricing, availability |
|
|
35
|
+
| **Interactive** | CSR | User interaction | Cart count, user selections |
|
|
36
|
+
|
|
37
|
+
### Phase-Based Type Validation
|
|
38
|
+
|
|
39
|
+
Jay Stack automatically generates **phase-specific ViewState types** from your contracts, ensuring that each render function can only return properties appropriate for its phase. This prevents accidentally including fast-changing data in slow renders or slow data in fast renders.
|
|
40
|
+
|
|
41
|
+
**Benefits:**
|
|
42
|
+
|
|
43
|
+
- 🛡️ **Compile-time safety**: TypeScript catches phase violations before deployment
|
|
44
|
+
- 📝 **Self-documenting**: The contract explicitly shows which data is static vs dynamic
|
|
45
|
+
- ⚡ **Performance**: Ensures optimal caching and rendering strategies
|
|
46
|
+
- 🎯 **Intent clarity**: Makes data lifecycle explicit in the contract
|
|
47
|
+
|
|
48
|
+
**Example:**
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// TypeScript automatically knows which properties are valid in each phase
|
|
52
|
+
.withSlowlyRender(async () => {
|
|
53
|
+
return partialRender({
|
|
54
|
+
productName: 'Widget', // ✅ Allowed (slow phase)
|
|
55
|
+
price: 29.99, // ❌ TypeScript Error: Not in SlowViewState
|
|
56
|
+
}, {});
|
|
57
|
+
})
|
|
58
|
+
.withFastRender(async () => {
|
|
59
|
+
return partialRender({
|
|
60
|
+
price: 29.99, // ✅ Allowed (fast phase)
|
|
61
|
+
productName: 'Widget', // ❌ TypeScript Error: Not in FastViewState
|
|
62
|
+
}, {});
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Specifying Phases in Contracts
|
|
67
|
+
|
|
68
|
+
You can annotate your contract properties with the `phase` attribute to control when data is rendered:
|
|
69
|
+
|
|
70
|
+
#### Jay HTML Contract
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<html>
|
|
74
|
+
<head>
|
|
75
|
+
<script type="application/yaml-jay">
|
|
76
|
+
data:
|
|
77
|
+
# Static data - rendered at build time
|
|
78
|
+
- {tag: productName, dataType: string, phase: slow}
|
|
79
|
+
- {tag: description, dataType: string, phase: slow}
|
|
80
|
+
- {tag: sku, dataType: string, phase: slow}
|
|
81
|
+
|
|
82
|
+
# Dynamic data - rendered per request
|
|
83
|
+
- {tag: price, dataType: number, phase: fast}
|
|
84
|
+
- {tag: inStock, dataType: boolean, phase: fast}
|
|
85
|
+
|
|
86
|
+
# No phase specified = defaults to 'slow'
|
|
87
|
+
- {tag: category, dataType: string}
|
|
88
|
+
</script>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<div>
|
|
92
|
+
<h1>{productName}</h1>
|
|
93
|
+
<p>{description}</p>
|
|
94
|
+
<p>Price: ${price}</p>
|
|
95
|
+
</div>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
#### Jay Contract (Headless)
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
name: product-contract
|
|
104
|
+
tags:
|
|
105
|
+
# Static product information
|
|
106
|
+
- tag: productName
|
|
107
|
+
dataType: string
|
|
108
|
+
phase: slow
|
|
109
|
+
|
|
110
|
+
- tag: description
|
|
111
|
+
dataType: string
|
|
112
|
+
phase: slow
|
|
113
|
+
|
|
114
|
+
- tag: sku
|
|
115
|
+
dataType: string
|
|
116
|
+
phase: slow
|
|
117
|
+
|
|
118
|
+
# Dynamic pricing and availability
|
|
119
|
+
- tag: price
|
|
120
|
+
dataType: number
|
|
121
|
+
phase: fast
|
|
122
|
+
|
|
123
|
+
- tag: inStock
|
|
124
|
+
dataType: boolean
|
|
125
|
+
phase: fast
|
|
126
|
+
|
|
127
|
+
# Interactive elements go in refs, not data
|
|
128
|
+
interactive:
|
|
129
|
+
- tag: addToCartButton
|
|
130
|
+
elementType: [button]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Phase Rules:**
|
|
134
|
+
|
|
135
|
+
- `slow`: Value is set at build time (default if not specified)
|
|
136
|
+
- `fast`: Value is set at request time
|
|
137
|
+
- `fast+interactive`: Value is set at request time and can be modified on the client
|
|
138
|
+
- `interactive` tags are implicitly `fast+interactive` and go into the `Refs` type, not `ViewState`
|
|
139
|
+
- For nested objects, the parent's phase serves as the default for children
|
|
140
|
+
- Array children cannot have an earlier phase than their parent array
|
|
39
141
|
|
|
40
142
|
## Installation
|
|
41
143
|
|
|
@@ -57,12 +159,15 @@ For headless components, create a Jay Contract file (`my-contract.jay-contract`)
|
|
|
57
159
|
<head>
|
|
58
160
|
<script type="application/yaml-jay">
|
|
59
161
|
data:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
162
|
+
# User profile data - slow changing
|
|
163
|
+
- {tag: id, dataType: string, phase: slow}
|
|
164
|
+
- {tag: name, dataType: string, phase: slow}
|
|
165
|
+
- {tag: age, dataType: number, phase: slow}
|
|
166
|
+
- {tag: address, dataType: string, phase: slow}
|
|
167
|
+
|
|
168
|
+
# User ratings - fast changing
|
|
169
|
+
- {tag: stars, dataType: number, phase: fast}
|
|
170
|
+
- {tag: rating, dataType: number, phase: fast}
|
|
66
171
|
</script>
|
|
67
172
|
</head>
|
|
68
173
|
<body>
|
|
@@ -84,18 +189,31 @@ For headless components, create a Jay Contract file (`my-contract.jay-contract`)
|
|
|
84
189
|
```yaml
|
|
85
190
|
name: my-contract
|
|
86
191
|
tags:
|
|
192
|
+
# User profile data - slow changing
|
|
87
193
|
- tag: id
|
|
88
194
|
dataType: string
|
|
195
|
+
phase: slow
|
|
196
|
+
|
|
89
197
|
- tag: name
|
|
90
198
|
dataType: string
|
|
199
|
+
phase: slow
|
|
200
|
+
|
|
91
201
|
- tag: age
|
|
92
202
|
dataType: number
|
|
203
|
+
phase: slow
|
|
204
|
+
|
|
93
205
|
- tag: address
|
|
94
206
|
dataType: string
|
|
207
|
+
phase: slow
|
|
208
|
+
|
|
209
|
+
# User ratings - fast changing
|
|
95
210
|
- tag: stars
|
|
96
211
|
dataType: number
|
|
212
|
+
phase: fast
|
|
213
|
+
|
|
97
214
|
- tag: rating
|
|
98
215
|
dataType: number
|
|
216
|
+
phase: fast
|
|
99
217
|
```
|
|
100
218
|
|
|
101
219
|
### 2. Generate Definition Files
|
|
@@ -106,6 +224,47 @@ Run the Jay CLI to generate TypeScript definition files from your Jay HTML or co
|
|
|
106
224
|
jay-cli definitions <path to your sources>
|
|
107
225
|
```
|
|
108
226
|
|
|
227
|
+
This will generate a `.d.ts` file with:
|
|
228
|
+
|
|
229
|
+
- **Full ViewState**: All properties from your contract
|
|
230
|
+
- **Phase-specific ViewStates**: Separate types for `Slow`, `Fast`, and `Interactive` phases
|
|
231
|
+
- **Contract type**: A `JayContract` type that includes all ViewState types
|
|
232
|
+
|
|
233
|
+
**Generated Types Example** (`my-component.jay-html.d.ts`):
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { JayContract } from '@jay-framework/fullstack-component';
|
|
237
|
+
|
|
238
|
+
// Full ViewState - all properties
|
|
239
|
+
export interface MyComponentViewState {
|
|
240
|
+
id: string;
|
|
241
|
+
name: string;
|
|
242
|
+
age: number;
|
|
243
|
+
address: string;
|
|
244
|
+
stars: number;
|
|
245
|
+
rating: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface MyComponentElementRefs {}
|
|
249
|
+
|
|
250
|
+
// Phase-specific ViewStates (automatically generated)
|
|
251
|
+
export type MyComponentSlowViewState = Pick<
|
|
252
|
+
MyComponentViewState,
|
|
253
|
+
'id' | 'name' | 'age' | 'address'
|
|
254
|
+
>;
|
|
255
|
+
export type MyComponentFastViewState = Pick<MyComponentViewState, 'stars' | 'rating'>;
|
|
256
|
+
export type MyComponentInteractiveViewState = {};
|
|
257
|
+
|
|
258
|
+
// Contract type with all ViewState types
|
|
259
|
+
export type MyComponentContract = JayContract<
|
|
260
|
+
MyComponentViewState,
|
|
261
|
+
MyComponentElementRefs,
|
|
262
|
+
MyComponentSlowViewState,
|
|
263
|
+
MyComponentFastViewState,
|
|
264
|
+
MyComponentInteractiveViewState
|
|
265
|
+
>;
|
|
266
|
+
```
|
|
267
|
+
|
|
109
268
|
### 3. Build Your Full-Stack Component
|
|
110
269
|
|
|
111
270
|
```typescript
|
|
@@ -258,16 +417,24 @@ After props, the function receives the services declared using `withServices`.
|
|
|
258
417
|
|
|
259
418
|
The function should return one of:
|
|
260
419
|
|
|
261
|
-
- `PartialRender<
|
|
420
|
+
- `PartialRender<SlowViewState, CarryForward>` - for partial rendering
|
|
262
421
|
- `ServerError5xx` - for server errors
|
|
263
422
|
- `Redirect3xx` - for semi-static redirects
|
|
264
423
|
|
|
424
|
+
**Type Safety:** TypeScript automatically validates that `partialRender` only receives properties from `SlowViewState` (as defined by `phase: slow` in your contract).
|
|
425
|
+
|
|
265
426
|
```typescript
|
|
266
427
|
makeJayStackComponent<MyComponentContract>()
|
|
267
428
|
.withServices(DATABASE_SERVICE)
|
|
268
429
|
.withSlowlyRender(async (props, database: Database) => {
|
|
269
430
|
const data = await database.getData();
|
|
270
|
-
return partialRender(
|
|
431
|
+
return partialRender(
|
|
432
|
+
{
|
|
433
|
+
productName: data.name, // ✅ OK if phase: slow
|
|
434
|
+
// price: data.price, // ❌ TypeScript error if phase: fast
|
|
435
|
+
},
|
|
436
|
+
{ carryForwardKey: data.id },
|
|
437
|
+
);
|
|
271
438
|
});
|
|
272
439
|
```
|
|
273
440
|
|
|
@@ -284,17 +451,26 @@ After that, the function receives the services declared using `withServices`.
|
|
|
284
451
|
|
|
285
452
|
The function should return one of:
|
|
286
453
|
|
|
287
|
-
- `PartialRender<
|
|
454
|
+
- `PartialRender<FastViewState, CarryForward>` - for partial rendering
|
|
288
455
|
- `ServerError5xx` - for server errors
|
|
289
456
|
- `ClientError4xx` - for client errors
|
|
290
457
|
- `Redirect3xx` - for dynamic redirects
|
|
291
458
|
|
|
459
|
+
**Type Safety:** TypeScript automatically validates that `partialRender` only receives properties from `FastViewState` (as defined by `phase: fast` in your contract).
|
|
460
|
+
|
|
292
461
|
```typescript
|
|
293
462
|
makeJayStackComponent<MyComponentContract>()
|
|
294
463
|
.withServices(INVENTORY_SERVICE)
|
|
295
464
|
.withFastRender(async (props, carryForward, inventory: InventoryService) => {
|
|
296
465
|
const status = await inventory.getStatus(carryForward.productId);
|
|
297
|
-
return partialRender(
|
|
466
|
+
return partialRender(
|
|
467
|
+
{
|
|
468
|
+
inStock: status.available > 0, // ✅ OK if phase: fast
|
|
469
|
+
price: 29.99, // ✅ OK if phase: fast
|
|
470
|
+
// productName: 'Widget', // ❌ TypeScript error if phase: slow
|
|
471
|
+
},
|
|
472
|
+
{ carryForwardKey: 'data' },
|
|
473
|
+
);
|
|
298
474
|
});
|
|
299
475
|
```
|
|
300
476
|
|
|
@@ -357,6 +533,507 @@ Creates a redirect response.
|
|
|
357
533
|
return redirect3xx(301, 'http://some.domain.com');
|
|
358
534
|
```
|
|
359
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
|
+
|
|
738
|
+
## Complete Example with Phase Validation
|
|
739
|
+
|
|
740
|
+
Here's a complete example showing how phase annotations in your contract provide compile-time type safety:
|
|
741
|
+
|
|
742
|
+
### 1. Define Contract with Phases
|
|
743
|
+
|
|
744
|
+
**`user-profile.jay-html`**:
|
|
745
|
+
|
|
746
|
+
```html
|
|
747
|
+
<html>
|
|
748
|
+
<head>
|
|
749
|
+
<script type="application/yaml-jay">
|
|
750
|
+
data:
|
|
751
|
+
# Static user info - rendered at build time
|
|
752
|
+
- {tag: userId, dataType: string, phase: slow}
|
|
753
|
+
- {tag: username, dataType: string, phase: slow}
|
|
754
|
+
- {tag: bio, dataType: string, phase: slow}
|
|
755
|
+
|
|
756
|
+
# Dynamic activity - rendered per request
|
|
757
|
+
- {tag: lastSeen, dataType: string, phase: fast}
|
|
758
|
+
- {tag: isOnline, dataType: boolean, phase: fast}
|
|
759
|
+
- {tag: followerCount, dataType: number, phase: fast}
|
|
760
|
+
</script>
|
|
761
|
+
</head>
|
|
762
|
+
<body>
|
|
763
|
+
<div>
|
|
764
|
+
<h1>{username}</h1>
|
|
765
|
+
<p>{bio}</p>
|
|
766
|
+
<p>Followers: {followerCount}</p>
|
|
767
|
+
<span>{isOnline ? 'Online' : 'Last seen: ' + lastSeen}</span>
|
|
768
|
+
</div>
|
|
769
|
+
</body>
|
|
770
|
+
</html>
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
### 2. Generated Types
|
|
774
|
+
|
|
775
|
+
**`user-profile.jay-html.d.ts`** (auto-generated):
|
|
776
|
+
|
|
777
|
+
```typescript
|
|
778
|
+
export interface UserProfileViewState {
|
|
779
|
+
userId: string;
|
|
780
|
+
username: string;
|
|
781
|
+
bio: string;
|
|
782
|
+
lastSeen: string;
|
|
783
|
+
isOnline: boolean;
|
|
784
|
+
followerCount: number;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export interface UserProfileElementRefs {}
|
|
788
|
+
|
|
789
|
+
// Phase-specific types - automatically generated
|
|
790
|
+
export type UserProfileSlowViewState = Pick<UserProfileViewState, 'userId' | 'username' | 'bio'>;
|
|
791
|
+
export type UserProfileFastViewState = Pick<
|
|
792
|
+
UserProfileViewState,
|
|
793
|
+
'lastSeen' | 'isOnline' | 'followerCount'
|
|
794
|
+
>;
|
|
795
|
+
export type UserProfileInteractiveViewState = {};
|
|
796
|
+
|
|
797
|
+
export type UserProfileContract = JayContract<
|
|
798
|
+
UserProfileViewState,
|
|
799
|
+
UserProfileElementRefs,
|
|
800
|
+
UserProfileSlowViewState,
|
|
801
|
+
UserProfileFastViewState,
|
|
802
|
+
UserProfileInteractiveViewState
|
|
803
|
+
>;
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
### 3. Implement with Type Safety
|
|
807
|
+
|
|
808
|
+
```typescript
|
|
809
|
+
import {
|
|
810
|
+
makeJayStackComponent,
|
|
811
|
+
partialRender,
|
|
812
|
+
createJayService,
|
|
813
|
+
} from '@jay-framework/fullstack-component';
|
|
814
|
+
import { UserProfileContract } from './user-profile.jay-html';
|
|
815
|
+
|
|
816
|
+
interface UserDatabase {
|
|
817
|
+
getUser(id: string): Promise<{ id: string; name: string; bio: string }>;
|
|
818
|
+
}
|
|
819
|
+
const USER_DB = createJayService<UserDatabase>('UserDB');
|
|
820
|
+
|
|
821
|
+
interface ActivityService {
|
|
822
|
+
getUserActivity(id: string): Promise<{ lastSeen: string; isOnline: boolean; followers: number }>;
|
|
823
|
+
}
|
|
824
|
+
const ACTIVITY_SERVICE = createJayService<ActivityService>('Activity');
|
|
825
|
+
|
|
826
|
+
export const userProfile = makeJayStackComponent<UserProfileContract>()
|
|
827
|
+
.withProps()
|
|
828
|
+
.withServices(USER_DB, ACTIVITY_SERVICE)
|
|
829
|
+
.withSlowlyRender(async (props, userDb) => {
|
|
830
|
+
// ✅ TypeScript knows only slow properties are allowed
|
|
831
|
+
const user = await userDb.getUser('123');
|
|
832
|
+
return partialRender(
|
|
833
|
+
{
|
|
834
|
+
userId: user.id,
|
|
835
|
+
username: user.name,
|
|
836
|
+
bio: user.bio,
|
|
837
|
+
// followerCount: 100, // ❌ TypeScript Error: Property 'followerCount'
|
|
838
|
+
// does not exist in type 'UserProfileSlowViewState'
|
|
839
|
+
},
|
|
840
|
+
{ userId: user.id },
|
|
841
|
+
);
|
|
842
|
+
})
|
|
843
|
+
.withFastRender(async (props, carryForward, userDb, activityService) => {
|
|
844
|
+
// ✅ TypeScript knows only fast properties are allowed
|
|
845
|
+
const activity = await activityService.getUserActivity(carryForward.userId);
|
|
846
|
+
return partialRender(
|
|
847
|
+
{
|
|
848
|
+
lastSeen: activity.lastSeen,
|
|
849
|
+
isOnline: activity.isOnline,
|
|
850
|
+
followerCount: activity.followers,
|
|
851
|
+
// username: 'John', // ❌ TypeScript Error: Property 'username'
|
|
852
|
+
// does not exist in type 'UserProfileFastViewState'
|
|
853
|
+
},
|
|
854
|
+
{},
|
|
855
|
+
);
|
|
856
|
+
})
|
|
857
|
+
.withInteractive((props, refs) => {
|
|
858
|
+
return {
|
|
859
|
+
render: () => ({}),
|
|
860
|
+
};
|
|
861
|
+
});
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
**Key Benefits:**
|
|
865
|
+
|
|
866
|
+
- 🔒 **Compile-time guarantees**: TypeScript prevents phase violations before deployment
|
|
867
|
+
- 📊 **Clear separation**: Slow (static) data is visually separated from fast (dynamic) data
|
|
868
|
+
- ⚡ **Optimal performance**: Framework can cache slow data aggressively
|
|
869
|
+
- 🧹 **No boilerplate**: No manual type annotations needed in render functions
|
|
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
|
+
|
|
360
1037
|
## Advanced Examples
|
|
361
1038
|
|
|
362
1039
|
### A Product Page with URL Parameters
|