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:
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