- 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
393 lines
9.8 KiB
Plaintext
393 lines
9.8 KiB
Plaintext
/**
|
|
* 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();
|