- Static compiler with STRC pattern (Static Template Resolution with
Compartmentalized Layers)
- Template syntax: { } interpolation, { s-for }, { s-if/s-elif/s-else
}
- File types: .strata, .compiler.sts, .service.sts, .api.sts, .sts,
.scss
- CLI tools: strata dev, strata build, strata g (generators)
- create-strata scaffolding CLI with Pokemon API example
- Dev server with WebSocket HMR (Hot Module Replacement)
- Documentation: README, ARCHITECTURE, CHANGELOG, CONTRIBUTING,
LICENSE
234 lines
6.3 KiB
Plaintext
234 lines
6.3 KiB
Plaintext
/**
|
|
* Strata Smart Fetch
|
|
* Deduplication, caching, and no re-render for unchanged data
|
|
*/
|
|
|
|
import type { Strata } from '../core/strata.sts';
|
|
|
|
interface FetchOptions {
|
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
headers?: Record<string, string>;
|
|
body?: any;
|
|
cache?: 'none' | 'smart' | 'permanent' | string; // string for TTL like '5m', '1h'
|
|
stale?: string; // stale-while-revalidate TTL
|
|
dedupe?: boolean; // default true
|
|
transform?: (data: any) => any;
|
|
}
|
|
|
|
interface QueryState<T> {
|
|
data: T | null;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
isStale: boolean;
|
|
}
|
|
|
|
type QuerySubscriber<T> = (state: QueryState<T>) => void;
|
|
|
|
export class StrataFetch {
|
|
private strata: any; // Strata instance
|
|
private queryCache: Map<string, QueryState<any>> = new Map();
|
|
private subscribers: Map<string, Set<QuerySubscriber<any>>> = new Map();
|
|
|
|
constructor(strata: any) {
|
|
this.strata = strata;
|
|
}
|
|
|
|
/**
|
|
* Basic fetch with deduplication and caching
|
|
*/
|
|
async fetch<T = any>(url: string, options: FetchOptions = {}): Promise<T> {
|
|
const fullUrl = this.resolveUrl(url);
|
|
|
|
const data = await this.strata.fetchViaWorker(fullUrl, {
|
|
method: options.method || 'GET',
|
|
headers: options.headers,
|
|
body: options.body,
|
|
cache: options.cache || 'smart',
|
|
});
|
|
|
|
return options.transform ? options.transform(data) : data;
|
|
}
|
|
|
|
/**
|
|
* GET request
|
|
*/
|
|
async get<T = any>(url: string, options?: Omit<FetchOptions, 'method'>): Promise<T> {
|
|
return this.fetch<T>(url, { ...options, method: 'GET' });
|
|
}
|
|
|
|
/**
|
|
* POST request
|
|
*/
|
|
async post<T = any>(url: string, body?: any, options?: Omit<FetchOptions, 'method' | 'body'>): Promise<T> {
|
|
return this.fetch<T>(url, { ...options, method: 'POST', body });
|
|
}
|
|
|
|
/**
|
|
* PUT request
|
|
*/
|
|
async put<T = any>(url: string, body?: any, options?: Omit<FetchOptions, 'method' | 'body'>): Promise<T> {
|
|
return this.fetch<T>(url, { ...options, method: 'PUT', body });
|
|
}
|
|
|
|
/**
|
|
* DELETE request
|
|
*/
|
|
async delete<T = any>(url: string, options?: Omit<FetchOptions, 'method'>): Promise<T> {
|
|
return this.fetch<T>(url, { ...options, method: 'DELETE' });
|
|
}
|
|
|
|
/**
|
|
* Reactive query hook - only re-renders when data changes
|
|
*/
|
|
useQuery<T = any>(
|
|
url: string,
|
|
options: FetchOptions = {}
|
|
): {
|
|
subscribe: (subscriber: QuerySubscriber<T>) => () => void;
|
|
refetch: () => Promise<void>;
|
|
getData: () => QueryState<T>;
|
|
} {
|
|
const cacheKey = this.getCacheKey(url, options);
|
|
|
|
// Initialize state if not exists
|
|
if (!this.queryCache.has(cacheKey)) {
|
|
this.queryCache.set(cacheKey, {
|
|
data: null,
|
|
loading: true,
|
|
error: null,
|
|
isStale: false,
|
|
});
|
|
|
|
// Start initial fetch
|
|
this.executeQuery(url, options, cacheKey);
|
|
}
|
|
|
|
return {
|
|
subscribe: (subscriber: QuerySubscriber<T>) => {
|
|
if (!this.subscribers.has(cacheKey)) {
|
|
this.subscribers.set(cacheKey, new Set());
|
|
}
|
|
this.subscribers.get(cacheKey)!.add(subscriber);
|
|
|
|
// Immediately call with current state
|
|
subscriber(this.queryCache.get(cacheKey)!);
|
|
|
|
// Return unsubscribe
|
|
return () => {
|
|
this.subscribers.get(cacheKey)?.delete(subscriber);
|
|
};
|
|
},
|
|
refetch: () => this.executeQuery(url, options, cacheKey),
|
|
getData: () => this.queryCache.get(cacheKey)!,
|
|
};
|
|
}
|
|
|
|
private async executeQuery(url: string, options: FetchOptions, cacheKey: string): Promise<void> {
|
|
const currentState = this.queryCache.get(cacheKey)!;
|
|
|
|
// Set loading (but keep old data for stale-while-revalidate)
|
|
this.updateQueryState(cacheKey, {
|
|
...currentState,
|
|
loading: true,
|
|
isStale: currentState.data !== null,
|
|
});
|
|
|
|
try {
|
|
const newData = await this.fetch(url, options);
|
|
|
|
// Deep compare with existing data
|
|
const hasChanged = !this.deepEqual(currentState.data, newData);
|
|
|
|
if (hasChanged) {
|
|
this.updateQueryState(cacheKey, {
|
|
data: newData,
|
|
loading: false,
|
|
error: null,
|
|
isStale: false,
|
|
});
|
|
} else {
|
|
// Data unchanged - just update loading state, no re-render trigger
|
|
const state = this.queryCache.get(cacheKey)!;
|
|
state.loading = false;
|
|
state.isStale = false;
|
|
// Don't notify subscribers since data didn't change
|
|
}
|
|
} catch (error) {
|
|
this.updateQueryState(cacheKey, {
|
|
...currentState,
|
|
loading: false,
|
|
error: error instanceof Error ? error : new Error('Unknown error'),
|
|
isStale: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
private updateQueryState<T>(cacheKey: string, state: QueryState<T>): void {
|
|
this.queryCache.set(cacheKey, state);
|
|
|
|
// Notify subscribers
|
|
const subs = this.subscribers.get(cacheKey);
|
|
if (subs) {
|
|
for (const subscriber of subs) {
|
|
subscriber(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
private getCacheKey(url: string, options: FetchOptions): string {
|
|
const method = options.method || 'GET';
|
|
const body = options.body ? JSON.stringify(options.body) : '';
|
|
return `${method}:${url}:${body}`;
|
|
}
|
|
|
|
private resolveUrl(url: string): string {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
return url;
|
|
}
|
|
// Use base URL from config if available
|
|
const baseUrl = (window as any).__STRATA_CONFIG__?.apiBaseUrl || '';
|
|
return baseUrl + url;
|
|
}
|
|
|
|
private deepEqual(a: any, b: any): boolean {
|
|
if (a === b) return true;
|
|
if (a === null || b === null) return false;
|
|
if (typeof a !== typeof b) return false;
|
|
|
|
if (typeof a === 'object') {
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
|
|
const keysA = Object.keys(a);
|
|
const keysB = Object.keys(b);
|
|
|
|
if (keysA.length !== keysB.length) return false;
|
|
|
|
for (const key of keysA) {
|
|
if (!this.deepEqual(a[key], b[key])) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Invalidate cache for specific URLs
|
|
*/
|
|
invalidate(pattern?: string): void {
|
|
this.strata.invalidateCache(pattern || '*');
|
|
|
|
// Also clear local query cache
|
|
if (!pattern || pattern === '*') {
|
|
this.queryCache.clear();
|
|
} else {
|
|
for (const key of this.queryCache.keys()) {
|
|
if (key.includes(pattern)) {
|
|
this.queryCache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|