git commit -m "feat: initial release of Strata framework v0.1.0
- 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
This commit is contained in:
231
runtime/core/strata.sts
Normal file
231
runtime/core/strata.sts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Strata Core Runtime
|
||||
* Main entry point for browser runtime
|
||||
*/
|
||||
|
||||
import { StrataStore, createStore, useStore } from '../store/store.sts';
|
||||
import { StrataFetch } from '../fetch/fetch.sts';
|
||||
|
||||
interface StrataConfig {
|
||||
encryptionKey?: number[];
|
||||
apiBaseUrl?: string;
|
||||
devMode?: boolean;
|
||||
}
|
||||
|
||||
interface BroadcastHandler {
|
||||
(data: any): void;
|
||||
}
|
||||
|
||||
class Strata {
|
||||
private worker: SharedWorker | null = null;
|
||||
private _tabId: string = '';
|
||||
private messageHandlers: Map<string, Function[]> = new Map();
|
||||
private broadcastHandlers: Map<string, BroadcastHandler[]> = new Map();
|
||||
private pendingRequests: Map<string, { resolve: Function; reject: Function }> = new Map();
|
||||
private config: StrataConfig = {};
|
||||
private _fetch: StrataFetch | null = null;
|
||||
|
||||
get tabId(): string {
|
||||
return this._tabId;
|
||||
}
|
||||
|
||||
get fetch(): StrataFetch {
|
||||
if (!this._fetch) {
|
||||
throw new Error('Strata not initialized. Call strata.init() first.');
|
||||
}
|
||||
return this._fetch;
|
||||
}
|
||||
|
||||
async init(config: StrataConfig = {}): Promise<void> {
|
||||
this.config = config;
|
||||
|
||||
// Initialize SharedWorker
|
||||
if (typeof SharedWorker !== 'undefined') {
|
||||
this.worker = new SharedWorker(
|
||||
new URL('../worker/shared-worker.sts', import.meta.url),
|
||||
{ type: 'module', name: 'strata-worker' }
|
||||
);
|
||||
|
||||
this.worker.port.onmessage = (event) => this.handleWorkerMessage(event.data);
|
||||
this.worker.port.start();
|
||||
|
||||
// Send encryption key if provided
|
||||
if (config.encryptionKey) {
|
||||
this.worker.port.postMessage({
|
||||
type: 'setEncryptionKey',
|
||||
key: config.encryptionKey,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for connection
|
||||
await this.waitForConnection();
|
||||
} else {
|
||||
// Fallback for browsers without SharedWorker support
|
||||
this._tabId = 'tab_' + Math.random().toString(36).slice(2, 10);
|
||||
console.warn('SharedWorker not supported, falling back to single-tab mode');
|
||||
}
|
||||
|
||||
// Initialize fetch
|
||||
this._fetch = new StrataFetch(this);
|
||||
|
||||
// Handle page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.disconnect();
|
||||
});
|
||||
|
||||
if (config.devMode) {
|
||||
(window as any).__STRATA__ = this;
|
||||
console.log(`Strata initialized (tabId: ${this._tabId})`);
|
||||
}
|
||||
}
|
||||
|
||||
private waitForConnection(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (message: any) => {
|
||||
if (message.type === 'connected') {
|
||||
this._tabId = message.tabId;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
this.addMessageHandler('connected', handler);
|
||||
});
|
||||
}
|
||||
|
||||
private handleWorkerMessage(message: any) {
|
||||
// Handle specific message types
|
||||
switch (message.type) {
|
||||
case 'connected':
|
||||
this._tabId = message.tabId;
|
||||
break;
|
||||
case 'fetch:response':
|
||||
case 'fetch:error':
|
||||
this.handleFetchResponse(message);
|
||||
break;
|
||||
case 'store:value':
|
||||
this.handleStoreValue(message);
|
||||
break;
|
||||
case 'broadcast':
|
||||
this.handleBroadcast(message);
|
||||
break;
|
||||
}
|
||||
|
||||
// Call registered handlers
|
||||
const handlers = this.messageHandlers.get(message.type) || [];
|
||||
for (const handler of handlers) {
|
||||
handler(message);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFetchResponse(message: any) {
|
||||
const pending = this.pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
if (message.type === 'fetch:error') {
|
||||
pending.reject(new Error(message.error));
|
||||
} else {
|
||||
pending.resolve(message.data);
|
||||
}
|
||||
this.pendingRequests.delete(message.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStoreValue(message: any) {
|
||||
// Handled by store subscriptions
|
||||
}
|
||||
|
||||
private handleBroadcast(message: any) {
|
||||
const handlers = this.broadcastHandlers.get(message.event) || [];
|
||||
for (const handler of handlers) {
|
||||
handler(message.data);
|
||||
}
|
||||
|
||||
// Also call wildcard handlers
|
||||
const wildcardHandlers = this.broadcastHandlers.get('*') || [];
|
||||
for (const handler of wildcardHandlers) {
|
||||
handler({ event: message.event, data: message.data });
|
||||
}
|
||||
}
|
||||
|
||||
private addMessageHandler(type: string, handler: Function) {
|
||||
if (!this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.set(type, []);
|
||||
}
|
||||
this.messageHandlers.get(type)!.push(handler);
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
sendToWorker(message: any): void {
|
||||
if (this.worker) {
|
||||
this.worker.port.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchViaWorker(url: string, options?: any): Promise<any> {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
this.sendToWorker({
|
||||
type: 'fetch',
|
||||
url,
|
||||
options,
|
||||
requestId,
|
||||
});
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(requestId)) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error('Request timeout'));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(event: string, data?: any, targetTabs?: string[]): void {
|
||||
this.sendToWorker({
|
||||
type: 'broadcast',
|
||||
event,
|
||||
data,
|
||||
targetTabs,
|
||||
});
|
||||
}
|
||||
|
||||
onBroadcast(event: string, handler: BroadcastHandler): () => void {
|
||||
if (!this.broadcastHandlers.has(event)) {
|
||||
this.broadcastHandlers.set(event, []);
|
||||
}
|
||||
this.broadcastHandlers.get(event)!.push(handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const handlers = this.broadcastHandlers.get(event);
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
invalidateCache(pattern: string = '*'): void {
|
||||
this.sendToWorker({
|
||||
type: 'cache:invalidate',
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
|
||||
private disconnect(): void {
|
||||
this.sendToWorker({ type: 'disconnect' });
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const strata = new Strata();
|
||||
|
||||
// Re-exports
|
||||
export { createStore, useStore };
|
||||
export type { StrataStore };
|
||||
233
runtime/fetch/fetch.sts
Normal file
233
runtime/fetch/fetch.sts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
260
runtime/store/store.sts
Normal file
260
runtime/store/store.sts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Strata Store
|
||||
* Fast state management with encryption and cross-tab sync
|
||||
*/
|
||||
|
||||
import { strata } from '../core/strata.sts';
|
||||
|
||||
type Subscriber<T> = (state: T, prevState: T) => void;
|
||||
type Selector<T, R> = (state: T) => R;
|
||||
|
||||
interface StoreOptions<T> {
|
||||
state: T;
|
||||
actions?: Record<string, (this: T & StoreActions<T>, ...args: any[]) => any>;
|
||||
encrypt?: boolean | string[]; // true = all, string[] = specific fields
|
||||
persist?: boolean | string[]; // true = all, string[] = specific fields
|
||||
shared?: boolean | string[]; // true = all, string[] = specific fields across tabs
|
||||
}
|
||||
|
||||
interface StoreActions<T> {
|
||||
$set: (partial: Partial<T>) => void;
|
||||
$reset: () => void;
|
||||
$subscribe: (subscriber: Subscriber<T>) => () => void;
|
||||
}
|
||||
|
||||
export interface StrataStore<T> extends StoreActions<T> {
|
||||
getState: () => T;
|
||||
setState: (partial: Partial<T>) => void;
|
||||
}
|
||||
|
||||
const stores: Map<string, StrataStore<any>> = new Map();
|
||||
|
||||
/**
|
||||
* Create a new store
|
||||
*/
|
||||
export function createStore<T extends object>(
|
||||
name: string,
|
||||
options: StoreOptions<T>
|
||||
): StrataStore<T> & T {
|
||||
if (stores.has(name)) {
|
||||
return stores.get(name) as StrataStore<T> & T;
|
||||
}
|
||||
|
||||
const initialState = { ...options.state };
|
||||
let state = { ...initialState };
|
||||
const subscribers: Set<Subscriber<T>> = new Set();
|
||||
|
||||
// Track which fields should be encrypted/persisted/shared
|
||||
const encryptFields = normalizeFields(options.encrypt);
|
||||
const persistFields = normalizeFields(options.persist);
|
||||
const sharedFields = normalizeFields(options.shared);
|
||||
|
||||
function normalizeFields(opt: boolean | string[] | undefined): Set<string> | 'all' | null {
|
||||
if (opt === true) return 'all';
|
||||
if (Array.isArray(opt)) return new Set(opt);
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldEncrypt(field: string): boolean {
|
||||
if (encryptFields === 'all') return true;
|
||||
if (encryptFields instanceof Set) return encryptFields.has(field);
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldPersist(field: string): boolean {
|
||||
if (persistFields === 'all') return true;
|
||||
if (persistFields instanceof Set) return persistFields.has(field);
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldShare(field: string): boolean {
|
||||
if (sharedFields === 'all') return true;
|
||||
if (sharedFields instanceof Set) return sharedFields.has(field);
|
||||
return false;
|
||||
}
|
||||
|
||||
function notify(prevState: T) {
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(state, prevState);
|
||||
}
|
||||
}
|
||||
|
||||
function setState(partial: Partial<T>) {
|
||||
const prevState = { ...state };
|
||||
const changes = Object.entries(partial);
|
||||
|
||||
for (const [key, value] of changes) {
|
||||
(state as any)[key] = value;
|
||||
|
||||
// Sync to SharedWorker
|
||||
if (shouldPersist(key) || shouldShare(key)) {
|
||||
strata.sendToWorker({
|
||||
type: 'store:set',
|
||||
storeName: name,
|
||||
key,
|
||||
value,
|
||||
scope: shouldShare(key) ? 'shared' : 'tab',
|
||||
encrypt: shouldEncrypt(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notify(prevState);
|
||||
}
|
||||
|
||||
function getState(): T {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
// Core store object
|
||||
const store: StrataStore<T> = {
|
||||
getState,
|
||||
setState,
|
||||
$set: setState,
|
||||
$reset: () => {
|
||||
setState(initialState as Partial<T>);
|
||||
},
|
||||
$subscribe: (subscriber: Subscriber<T>) => {
|
||||
subscribers.add(subscriber);
|
||||
return () => subscribers.delete(subscriber);
|
||||
},
|
||||
};
|
||||
|
||||
// Bind actions
|
||||
if (options.actions) {
|
||||
for (const [actionName, actionFn] of Object.entries(options.actions)) {
|
||||
(store as any)[actionName] = function (...args: any[]) {
|
||||
return actionFn.apply(
|
||||
new Proxy(state, {
|
||||
set(target, prop, value) {
|
||||
setState({ [prop]: value } as Partial<T>);
|
||||
return true;
|
||||
},
|
||||
get(target, prop) {
|
||||
if (prop === '$set') return setState;
|
||||
if (prop === '$reset') return store.$reset;
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}),
|
||||
args
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create proxy for direct property access
|
||||
const proxy = new Proxy(store as StrataStore<T> & T, {
|
||||
get(target, prop: string) {
|
||||
if (prop in target) return (target as any)[prop];
|
||||
return state[prop as keyof T];
|
||||
},
|
||||
set(target, prop: string, value) {
|
||||
if (prop in state) {
|
||||
setState({ [prop]: value } as Partial<T>);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for cross-tab updates
|
||||
strata.onBroadcast('store:changed', (data: any) => {
|
||||
if (data.storeName === name && shouldShare(data.key)) {
|
||||
const prevState = { ...state };
|
||||
(state as any)[data.key] = data.value;
|
||||
notify(prevState);
|
||||
}
|
||||
});
|
||||
|
||||
// Load persisted state
|
||||
loadPersistedState(name, state, persistFields, sharedFields);
|
||||
|
||||
stores.set(name, proxy);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use store with optional selector
|
||||
*/
|
||||
export function useStore<T, R = T>(
|
||||
store: StrataStore<T>,
|
||||
selector?: Selector<T, R>
|
||||
): R {
|
||||
const state = store.getState();
|
||||
return selector ? selector(state) : (state as unknown as R);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to store changes with selector
|
||||
*/
|
||||
export function subscribeToStore<T, R>(
|
||||
store: StrataStore<T>,
|
||||
selector: Selector<T, R>,
|
||||
callback: (value: R, prevValue: R) => void
|
||||
): () => void {
|
||||
let prevValue = selector(store.getState());
|
||||
|
||||
return store.$subscribe((state, prevState) => {
|
||||
const newValue = selector(state);
|
||||
const oldValue = selector(prevState);
|
||||
|
||||
// Only call if selected value changed
|
||||
if (!deepEqual(newValue, oldValue)) {
|
||||
callback(newValue, prevValue);
|
||||
prevValue = newValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPersistedState<T>(
|
||||
storeName: string,
|
||||
state: T,
|
||||
persistFields: Set<string> | 'all' | null,
|
||||
sharedFields: Set<string> | 'all' | null
|
||||
): Promise<void> {
|
||||
// Load from SharedWorker on init
|
||||
const fields = new Set<string>();
|
||||
|
||||
if (persistFields === 'all' || sharedFields === 'all') {
|
||||
Object.keys(state as object).forEach((k) => fields.add(k));
|
||||
} else {
|
||||
if (persistFields instanceof Set) persistFields.forEach((k) => fields.add(k));
|
||||
if (sharedFields instanceof Set) sharedFields.forEach((k) => fields.add(k));
|
||||
}
|
||||
|
||||
// Request each field from worker
|
||||
// (In production, batch this into single request)
|
||||
for (const field of fields) {
|
||||
strata.sendToWorker({
|
||||
type: 'store:get',
|
||||
storeName,
|
||||
key: field,
|
||||
scope: sharedFields === 'all' || (sharedFields instanceof Set && sharedFields.has(field))
|
||||
? 'shared'
|
||||
: 'tab',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function 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 (!deepEqual(a[key], b[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
392
runtime/worker/shared-worker.sts
Normal file
392
runtime/worker/shared-worker.sts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Strata Shared Worker
|
||||
* Manages cross-tab state, caching, and API deduplication
|
||||
*/
|
||||
|
||||
interface TabConnection {
|
||||
port: MessagePort;
|
||||
tabId: string;
|
||||
connectedAt: number;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
interface InFlightRequest {
|
||||
promise: Promise<any>;
|
||||
subscribers: string[]; // tabIds waiting for this request
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
[storeName: string]: {
|
||||
shared: Record<string, any>;
|
||||
tabs: Record<string, Record<string, any>>;
|
||||
};
|
||||
}
|
||||
|
||||
class StrataSharedWorker {
|
||||
private tabs: Map<string, TabConnection> = new Map();
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private inFlight: Map<string, InFlightRequest> = new Map();
|
||||
private stores: StoreState = {};
|
||||
private encryptionKey: Uint8Array | null = null;
|
||||
|
||||
constructor() {
|
||||
self.onconnect = (e: MessageEvent) => this.handleConnect(e);
|
||||
}
|
||||
|
||||
private generateTabId(): string {
|
||||
return 'tab_' + crypto.randomUUID().slice(0, 8);
|
||||
}
|
||||
|
||||
private handleConnect(e: MessageEvent) {
|
||||
const port = e.ports[0];
|
||||
const tabId = this.generateTabId();
|
||||
|
||||
const connection: TabConnection = {
|
||||
port,
|
||||
tabId,
|
||||
connectedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
};
|
||||
|
||||
this.tabs.set(tabId, connection);
|
||||
|
||||
port.onmessage = (event) => this.handleMessage(tabId, event.data);
|
||||
port.start();
|
||||
|
||||
// Send tabId to the new tab
|
||||
port.postMessage({
|
||||
type: 'connected',
|
||||
tabId,
|
||||
tabCount: this.tabs.size,
|
||||
});
|
||||
|
||||
// Notify other tabs
|
||||
this.broadcast('tab:joined', { tabId, tabCount: this.tabs.size }, [tabId]);
|
||||
}
|
||||
|
||||
private handleMessage(tabId: string, message: any) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab) {
|
||||
tab.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'fetch':
|
||||
this.handleFetch(tabId, message);
|
||||
break;
|
||||
case 'store:get':
|
||||
this.handleStoreGet(tabId, message);
|
||||
break;
|
||||
case 'store:set':
|
||||
this.handleStoreSet(tabId, message);
|
||||
break;
|
||||
case 'store:subscribe':
|
||||
this.handleStoreSubscribe(tabId, message);
|
||||
break;
|
||||
case 'broadcast':
|
||||
this.handleBroadcast(tabId, message);
|
||||
break;
|
||||
case 'cache:invalidate':
|
||||
this.handleCacheInvalidate(message);
|
||||
break;
|
||||
case 'disconnect':
|
||||
this.handleDisconnect(tabId);
|
||||
break;
|
||||
case 'setEncryptionKey':
|
||||
this.setEncryptionKey(message.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFetch(tabId: string, message: any) {
|
||||
const { url, options, requestId } = message;
|
||||
const cacheKey = this.getCacheKey(url, options);
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && !this.isCacheExpired(cached)) {
|
||||
this.sendToTab(tabId, {
|
||||
type: 'fetch:response',
|
||||
requestId,
|
||||
data: cached.data,
|
||||
fromCache: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if request is already in flight
|
||||
const inFlight = this.inFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
// Add this tab to subscribers
|
||||
inFlight.subscribers.push(tabId);
|
||||
const data = await inFlight.promise;
|
||||
this.sendToTab(tabId, {
|
||||
type: 'fetch:response',
|
||||
requestId,
|
||||
data,
|
||||
deduplicated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const fetchPromise = this.executeFetch(url, options);
|
||||
this.inFlight.set(cacheKey, {
|
||||
promise: fetchPromise,
|
||||
subscribers: [tabId],
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await fetchPromise;
|
||||
const inFlightEntry = this.inFlight.get(cacheKey);
|
||||
|
||||
// Cache the response
|
||||
if (options?.cache !== 'none') {
|
||||
const ttl = this.parseTTL(options?.cache || 'smart');
|
||||
this.cache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
});
|
||||
}
|
||||
|
||||
// Send to all subscribers
|
||||
if (inFlightEntry) {
|
||||
for (const subTabId of inFlightEntry.subscribers) {
|
||||
this.sendToTab(subTabId, {
|
||||
type: 'fetch:response',
|
||||
requestId,
|
||||
data,
|
||||
fromCache: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const inFlightEntry = this.inFlight.get(cacheKey);
|
||||
if (inFlightEntry) {
|
||||
for (const subTabId of inFlightEntry.subscribers) {
|
||||
this.sendToTab(subTabId, {
|
||||
type: 'fetch:error',
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.inFlight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeFetch(url: string, options: any): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
method: options?.method || 'GET',
|
||||
headers: options?.headers,
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private getCacheKey(url: string, options: any): string {
|
||||
const method = options?.method || 'GET';
|
||||
const body = options?.body ? JSON.stringify(options.body) : '';
|
||||
return `${method}:${url}:${body}`;
|
||||
}
|
||||
|
||||
private isCacheExpired(entry: CacheEntry): boolean {
|
||||
if (entry.ttl === -1) return false; // permanent
|
||||
return Date.now() - entry.timestamp > entry.ttl;
|
||||
}
|
||||
|
||||
private parseTTL(cache: string): number {
|
||||
if (cache === 'permanent') return -1;
|
||||
if (cache === 'none') return 0;
|
||||
if (cache === 'smart') return 5 * 60 * 1000; // 5 minutes default
|
||||
|
||||
const match = cache.match(/^(\d+)(s|m|h|d)$/);
|
||||
if (match) {
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return value * multipliers[unit];
|
||||
}
|
||||
|
||||
return 5 * 60 * 1000; // default 5 minutes
|
||||
}
|
||||
|
||||
private handleStoreGet(tabId: string, message: any) {
|
||||
const { storeName, key, scope } = message;
|
||||
const store = this.stores[storeName];
|
||||
|
||||
if (!store) {
|
||||
this.sendToTab(tabId, {
|
||||
type: 'store:value',
|
||||
storeName,
|
||||
key,
|
||||
value: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let value;
|
||||
if (scope === 'tab') {
|
||||
value = store.tabs[tabId]?.[key];
|
||||
} else {
|
||||
value = store.shared[key];
|
||||
}
|
||||
|
||||
// Decrypt if needed
|
||||
if (this.encryptionKey && value) {
|
||||
value = this.decrypt(value);
|
||||
}
|
||||
|
||||
this.sendToTab(tabId, {
|
||||
type: 'store:value',
|
||||
storeName,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
private handleStoreSet(tabId: string, message: any) {
|
||||
const { storeName, key, value, scope, encrypt } = message;
|
||||
|
||||
if (!this.stores[storeName]) {
|
||||
this.stores[storeName] = { shared: {}, tabs: {} };
|
||||
}
|
||||
|
||||
let storedValue = value;
|
||||
if (encrypt && this.encryptionKey) {
|
||||
storedValue = this.encrypt(value);
|
||||
}
|
||||
|
||||
if (scope === 'tab') {
|
||||
if (!this.stores[storeName].tabs[tabId]) {
|
||||
this.stores[storeName].tabs[tabId] = {};
|
||||
}
|
||||
this.stores[storeName].tabs[tabId][key] = storedValue;
|
||||
} else {
|
||||
this.stores[storeName].shared[key] = storedValue;
|
||||
|
||||
// Notify all tabs about shared state change
|
||||
this.broadcast('store:changed', {
|
||||
storeName,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleStoreSubscribe(tabId: string, message: any) {
|
||||
// Store subscriptions are handled via broadcast
|
||||
// When store changes, all tabs receive updates
|
||||
}
|
||||
|
||||
private handleBroadcast(fromTabId: string, message: any) {
|
||||
const { event, data, targetTabs } = message;
|
||||
this.broadcast(event, data, targetTabs ? undefined : [fromTabId]);
|
||||
}
|
||||
|
||||
private handleCacheInvalidate(message: any) {
|
||||
const { pattern } = message;
|
||||
|
||||
if (pattern === '*') {
|
||||
this.cache.clear();
|
||||
} else {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(tabId: string) {
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// Clean up tab-specific store data
|
||||
for (const storeName in this.stores) {
|
||||
delete this.stores[storeName].tabs[tabId];
|
||||
}
|
||||
|
||||
// Notify remaining tabs
|
||||
this.broadcast('tab:left', { tabId, tabCount: this.tabs.size });
|
||||
}
|
||||
|
||||
private broadcast(event: string, data: any, excludeTabs: string[] = []) {
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
if (!excludeTabs.includes(tabId)) {
|
||||
tab.port.postMessage({
|
||||
type: 'broadcast',
|
||||
event,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendToTab(tabId: string, message: any) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab) {
|
||||
tab.port.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private setEncryptionKey(keyArray: number[]) {
|
||||
this.encryptionKey = new Uint8Array(keyArray);
|
||||
}
|
||||
|
||||
private encrypt(data: any): string {
|
||||
if (!this.encryptionKey) return JSON.stringify(data);
|
||||
|
||||
const jsonStr = JSON.stringify(data);
|
||||
const encoder = new TextEncoder();
|
||||
const dataBytes = encoder.encode(jsonStr);
|
||||
|
||||
// XOR encryption with key (simple, fast)
|
||||
// In production, use SubtleCrypto AES-GCM
|
||||
const encrypted = new Uint8Array(dataBytes.length);
|
||||
for (let i = 0; i < dataBytes.length; i++) {
|
||||
encrypted[i] = dataBytes[i] ^ this.encryptionKey[i % this.encryptionKey.length];
|
||||
}
|
||||
|
||||
return btoa(String.fromCharCode(...encrypted));
|
||||
}
|
||||
|
||||
private decrypt(encryptedStr: string): any {
|
||||
if (!this.encryptionKey) return JSON.parse(encryptedStr);
|
||||
|
||||
const encrypted = new Uint8Array(
|
||||
atob(encryptedStr)
|
||||
.split('')
|
||||
.map((c) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
const decrypted = new Uint8Array(encrypted.length);
|
||||
for (let i = 0; i < encrypted.length; i++) {
|
||||
decrypted[i] = encrypted[i] ^ this.encryptionKey[i % this.encryptionKey.length];
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return JSON.parse(decoder.decode(decrypted));
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize worker
|
||||
new StrataSharedWorker();
|
||||
Reference in New Issue
Block a user