@stackwright-pro/pulse
v0.3.0-alpha.18
Published
Source-agnostic real-time data polling for Stackwright Pro
Downloads
883
Readme
@stackwright-pro/pulse
Source-agnostic real-time data polling for Stackwright Pro. Works with OpenAPI, WebSockets, GraphQL, SSE, or any data source.
Features
- 🔌 Source-Agnostic - Works with ANY async data source via a simple
fetcherinterface - ⏱️ Real-time Polling - Configurable polling intervals with automatic retries
- 📊 State Management - Built-in states:
loading,live,stale,error - 🛡️ Runtime Validation - Optional Zod schema validation for data integrity
- ⚡ React Query Powered - Leverages TanStack Query for caching and background updates
- 🎨 Flexible UI - Render props with metadata for custom state indicators
Installation
pnpm add @stackwright-pro/pulsePeer Dependencies:
pnpm add react react-dom @tanstack/react-queryQuick Start
Basic Usage
import { Pulse, PulseIndicator } from '@stackwright-pro/pulse';
function EquipmentPanel() {
return (
<Pulse fetcher={() => fetch('/api/equipment').then((r) => r.json())} interval={5000}>
{(equipment, meta) => (
<>
<PulseIndicator meta={meta} />
<EquipmentGrid data={equipment} />
</>
)}
</Pulse>
);
}With Zod Validation
import { z } from 'zod';
import { Pulse } from '@stackwright-pro/pulse';
const EquipmentSchema = z.object({
id: z.string(),
name: z.string(),
status: z.enum(['online', 'offline', 'maintenance']),
lastUpdated: z.string().datetime(),
});
function EquipmentPanel() {
return (
<Pulse
fetcher={() => fetch('/api/equipment').then((r) => r.json())}
schema={EquipmentSchema}
interval={5000}
>
{(equipment) => <EquipmentGrid data={equipment} />}
</Pulse>
);
}Custom State UI
import { Pulse } from '@stackwright-pro/pulse';
function CustomEquipmentPanel() {
return (
<Pulse
fetcher={fetchEquipment}
interval={5000}
staleThreshold={30000}
maxStaleAge={60000}
loadingState={<SkeletonLoader />}
errorState={(meta) => (
<ErrorBanner
message="Failed to load equipment"
lastUpdated={meta.lastUpdated}
onRetry={meta.refetch}
/>
)}
emptyState={<EmptyState message="No equipment found" />}
>
{(equipment, meta) => (
<>
{meta.state === 'stale' && <StaleBanner />}
<EquipmentGrid data={equipment} />
</>
)}
</Pulse>
);
}Architecture
┌─────────────────────────────────────────────────────────────┐
│ <Pulse> Component │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Render │ │ usePulse │ │ React Query │ │
│ │ Props │───▶│ Hook │───▶│ Provider │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ PulseMeta │ │ Fetcher │ │ Caching & │ │
│ │ (metadata) │ │ Validation │ │ Polling │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ ANY Data Source │
├───────────────────────────────────┤
│ • OpenAPI (fetch/axios) │
│ • WebSocket │
│ • GraphQL (urql/Apollo) │
│ • SSE (EventSource) │
│ • Custom async functions │
└───────────────────────────────────┘API Reference
<Pulse> Component
interface PulseProps<T> {
// Core
fetcher: () => Promise<T>; // Your data fetching function
children: (data: T, meta: PulseMeta) => React.ReactNode;
// Polling config
interval?: number; // Poll interval in ms (default: 5000, min: 1000)
enabled?: boolean; // Enable/disable polling (default: true)
retryCount?: number; // Number of retries on error (default: 3)
refetchOnWindowFocus?: boolean; // Refetch on window focus (default: true)
// State thresholds (in milliseconds)
staleThreshold?: number; // Show stale warning (default: 30000)
maxStaleAge?: number; // Show error state (default: 60000)
// Validation
schema?: ZodSchema<T>; // Optional Zod schema
// Custom state UI
loadingState?: React.ReactNode;
errorState?: React.ReactNode | ((meta: PulseMeta) => React.ReactNode);
emptyState?: React.ReactNode;
showStaleDataOnError?: boolean; // Show data during error (default: true)
}PulseMeta (passed to children)
interface PulseMeta {
lastUpdated: Date; // Timestamp of last successful fetch
isStale: boolean; // Data older than staleThreshold
isError: boolean; // Data older than maxStaleAge or fetch failed
isLoading: boolean; // Currently fetching
errorCount: number; // Number of consecutive errors
refetch: () => void; // Manual refresh trigger
state: 'loading' | 'live' | 'stale' | 'error';
}<PulseIndicator> Component
interface PulseIndicatorProps {
meta: PulseMeta; // Required meta object
showSeconds?: boolean; // Show "Xs ago" (default: true)
className?: string; // Additional CSS classes
labels?: {
live?: string; // Custom live label
syncing?: string; // Custom syncing label
stale?: string; // Custom stale label
error?: string; // Custom error label
};
}usePulse Hook
const result = usePulse<T>({
fetcher,
interval = 5000,
staleThreshold = 30000,
maxStaleAge = 60000,
schema,
enabled = true,
refetchOnWindowFocus = true,
retryCount = 3,
});
// Returns
{
data: T | undefined,
meta: PulseMeta,
state: PulseState,
error: Error | null,
}Source Adapters
The fetcher prop accepts any async function. This makes Pulse work with:
| Source | Example Fetcher |
| ------------ | ---------------------------------------------- |
| REST/OpenAPI | () => fetch('/api/data').then(r => r.json()) |
| WebSocket | () => websocket.nextMessage() |
| GraphQL | () => client.query({ query: MY_QUERY }) |
| SSE | () => eventSource.nextEvent() |
See SOURCE_ADAPTERS.md for planned first-party adapter documentation.
State Transitions
┌──────────┐
│ loading │ ← Initial state
└────┬─────┘
│ data received
▼
┌──────┐
│ live │ ← Normal operation
└──┬───┘
│ time > staleThreshold
▼
┌───────┐
│ stale │ ← Data is old but usable
└──┬───┘
│ time > maxStaleAge OR fetch error
▼
┌───────┐
│ error │ ← Data may be stale, show error UI
└──┬───┘
│ successful refetch
▼
┌──────┐
│ live │ ← Back to normal
└──────┘Validation
Use Zod schemas to validate incoming data:
import { z } from 'zod';
import { createPulseValidator, PulseValidationError } from '@stackwright-pro/pulse';
const validator = createPulseValidator(MySchema);
// Safe parse - returns null on failure
const data = validator.safeParse(rawData);
// Throws PulseValidationError on failure with detailed issues
const data = validator.parse(rawData);Best Practices
- Use appropriate intervals - Consider network cost vs. data freshness needs
- Handle empty states - Always provide
emptyStatefor collections - Validate early - Use Zod schemas to catch data contract issues
- Leverage meta state - Use
meta.statefor fine-grained UI control - Cleanup properly - Disable polling when component unmounts if needed
Live Demo
See the Marine Logistics Demo for a complete example of Pulse with OpenAPI integration:
import { Pulse, PulseIndicator } from '@stackwright-pro/pulse';
import { createOpenAPIFetcher } from '@stackwright-pro/openapi';
import type { Equipment } from './generated/types';
import { EquipmentSchema } from './generated/schemas';
// Create typed fetcher from OpenAPI config
const fetcher = createOpenAPIFetcher({
baseUrl: 'https://api.logistics.example.mil/v1',
endpoint: '/equipment',
auth: { type: 'bearer', token: process.env.API_TOKEN },
});
function EquipmentDashboard() {
return (
<Pulse
fetcher={fetcher}
interval={5000}
staleThreshold={30000}
maxStaleAge={60000}
schema={EquipmentSchema}
loadingState={<SkeletonLoader />}
>
{(equipment: Equipment[], meta) => (
<>
<PulseIndicator meta={meta} />
<div className="equipment-grid">
{equipment.map((item) => (
<EquipmentCard key={item.id} data={item} />
))}
</div>
</>
)}
</Pulse>
);
}For a full working demo, see examples/marine-logistics-demo/src/pages/dashboard-live.tsx
Future Features
- [ ]
createWebSocketFetcher()- Real-time WebSocket adapter - [ ]
createGraphQLFetcher()- GraphQL query polling adapter - [ ]
createSSEFetcher()- Server-Sent Events adapter - [ ] Streaming mode via
useStreaminghook
See docs/SOURCE_ADAPTERS.md for planned adapter documentation.
Collection Binding (Phase 3 - Killer Pro Feature)
The collection binding system connects OpenAPI data → Prebuild → Pulse → Dashboard Components for a complete live data experience.
Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ DASHBOARD OTTER │
│ Generates YAML with {{ collection.field }} template syntax │
└─────────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ stackwright.yml │
│ integrations: │
│ - name: logistics-api │
│ spec: ./openapi.yaml │
│ collections: │
│ - endpoint: /equipment │
└─────────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ PREBUILD (pnpm prebuild) │
│ OpenAPIPlugin generates: │
│ - src/generated/logistics-api/provider.ts (CollectionProvider) │
│ - src/generated/logistics-api/schemas.ts (Zod schemas) │
│ - src/generated/logistics-api/types.ts (TypeScript types) │
└─────────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ PulseCollectionProvider │
│ Wraps dashboard with live collection context │
│ - Creates fetchers from prebuild providers │
│ - Polls at configured intervals (5s, 30s, 300s, etc.) │
│ - Provides data via React context │
└─────────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Pulse-Enabled Components │
│ MetricCardPulse, DataTablePulse, StatusBadgePulse │
│ - Auto-bind to collection data │
│ - Support template syntax {{ collection.field }} │
│ - Built-in aggregation (count, sum, avg) │
│ - Live indicator with stale/error states │
└──────────────────────────────────────────────────────────────────────────┘Quick Start
1. Configure OpenAPI in stackwright.yml
integrations:
- name: logistics-api
spec: ./openapi.yaml
auth:
type: bearer
token: ${API_TOKEN}
collections:
- endpoint: /equipment
slug_field: id
- endpoint: /locations
slug_field: id2. Run Prebuild
pnpm prebuild
# Generates src/generated/logistics-api/*3. Use Pulse Components in Pages
# pages/dashboard/content.yml
content:
meta:
title: 'Live Equipment Dashboard'
content_items:
# Wrap with Pulse provider for live data
- type: pulse_provider
label: equipment-live
collections:
- name: equipment
endpoint: /api/equipment
refreshInterval: 5000
items:
- type: grid
columns: 4
items:
- type: metric_card_pulse
collection: equipment
field: items.length
label: 'Total Equipment'
icon: Truck
- type: metric_card_pulse
collection: equipment
field: items
label: 'Active'
aggregate: count
aggregateField: status
filter: '{"status": "active"}'
icon: CheckCircle
- type: data_table_pulse
collection: equipment
columns:
- field: id
header: ID
sortable: true
- field: name
header: Name
- field: status
header: Status
type: badge
sortBy: name
sortDirection: asc
limit: 20Component Reference
pulse_provider
Wraps content with live collection data. All child Pulse components automatically bind to these collections.
- type: pulse_provider
label: equipment-live
collections:
- name: equipment # Collection name for binding
endpoint: /api/equipment # OpenAPI endpoint
refreshInterval: 5000 # Poll every 5 seconds
filter: # Optional server-side filter
status: active
items:
# Child components hereProps:
| Prop | Type | Description |
|------|------|-------------|
| label | string | Unique identifier |
| collections | CollectionBinding[] | Collections to fetch |
| collections[].name | string | Collection name for binding |
| collections[].endpoint | string | API endpoint path |
| collections[].refreshInterval | number | Poll interval in ms (default: 5000) |
| collections[].filter | object | Server-side filter params |
metric_card_pulse
KPI card with auto-bound live data.
- type: metric_card_pulse
collection: equipment # Collection to bind
field: items.length # Field or expression
label: 'Total Equipment' # Display label
icon: Truck # Icon name
aggregate: count # Optional: count, sum, avg
aggregateField: status # Field for aggregation
filter: '{"status": "active"}' # Optional: filter itemsProps:
| Prop | Type | Description |
|------|------|-------------|
| collection | string | Collection name |
| field | string | Field path (supports dots) |
| label | string | Card label |
| icon | ReactNode | Icon component |
| color | string | Accent color (hex) |
| trend | 'up' | 'down' | 'stable' | Trend direction |
| trendValue | string | Trend display value |
| aggregate | 'count' | 'sum' | 'avg' | Aggregate function |
| aggregateField | string | Field for aggregation |
| filter | string | JSON filter string |
data_table_pulse
Sortable table with live data binding.
- type: data_table_pulse
collection: equipment
columns:
- field: id
header: ID
sortable: true
- field: name
header: Name
- field: status
header: Status
type: badge
sortBy: name
sortDirection: asc
limit: 20Props:
| Prop | Type | Description |
|------|------|-------------|
| collection | string | Collection name |
| columns | Column[] | Column definitions |
| sortBy | string | Field to sort by |
| sortDirection | 'asc' | 'desc' | Sort direction |
| filter | string | JSON filter string |
| limit | number | Max items to show |
| onRowClick | function | Row click handler |
| emptyMessage | string | Empty state message |
status_badge_pulse
Status indicator with live data binding.
- type: status_badge_pulse
collection: equipment
field: items[0].status
label: 'System Status'
pulse: trueProps:
| Prop | Type | Description |
|------|------|-------------|
| collection | string | Collection name |
| field | string | Status field path |
| label | string | Display label |
| pulse | boolean | Show pulse animation |
| statusMap | object | Custom value → status mapping |
Hooks
For custom component integration:
import {
usePulseCollections, // Full context
useCollection, // Single collection
useCollectionField, // Specific field
useTemplateResolution, // Resolve templates
resolveTemplate, // Static template resolver
} from '@stackwright-pro/pulse';
// Get full context
const { collections, isLoading, getField } = usePulseCollections();
// Get single collection
const equipment = useCollection('equipment');
// Returns: { items, count, meta, ... }
// Get specific field
const count = useCollectionField('equipment', 'items.length');
// Resolve template anywhere
const templateResolution = useTemplateResolution();
const value = templateResolution('{{ equipment.count }}');Template Syntax
The {{ collection.field }} syntax is automatically resolved:
| Pattern | Example | Result |
| ------------ | ------------------------------- | --------------------- |
| Count | {{ equipment.count }} | Number of items |
| Array length | {{ equipment.items.length }} | Array size |
| Nested field | {{ equipment.items[0].name }} | First item's name |
| Aggregate | {{ equipment.status.active }} | Count of active items |
ISR Intervals
Configure refresh intervals based on data freshness needs:
| Interval | Use Case | Example | | -------- | ------------- | ------------------ | | 5000ms | Real-time | Live dashboard | | 30000ms | Near-realtime | Status updates | | 300000ms | Hourly | Reports, summaries |
Error Handling
Pulse components handle errors gracefully:
// With error state
<DataTablePulse
collection="equipment"
columns={columns}
fallback={<ErrorBanner />}
/>
// With stale data (continue showing data during errors)
<MetricCardPulse
collection="equipment"
field="count"
showStaleData={true}
/>Complete Example
See the marine-logistics demo for a full working example:
pnpm prebuild # Generate providers
pnpm dev # Start dev serverThe dashboard will show live-updating KPI cards and data tables with auto-refresh!
Future: Real-time Providers
Coming soon:
createWebSocketFetcher()- WebSocket adaptercreateSSEFetcher()- Server-Sent Events adaptercreateGraphQLFetcher()- GraphQL polling adapter
License
PROPRIETARY - All rights reserved.
