|
import type { WebContainer } from '@webcontainer/api'; |
|
import { atom } from 'nanostores'; |
|
|
|
|
|
declare global { |
|
interface Window { |
|
_tabId?: string; |
|
} |
|
} |
|
|
|
export interface PreviewInfo { |
|
port: number; |
|
ready: boolean; |
|
baseUrl: string; |
|
} |
|
|
|
|
|
const PREVIEW_CHANNEL = 'preview-updates'; |
|
|
|
export class PreviewsStore { |
|
#availablePreviews = new Map<number, PreviewInfo>(); |
|
#webcontainer: Promise<WebContainer>; |
|
#broadcastChannel: BroadcastChannel; |
|
#lastUpdate = new Map<string, number>(); |
|
#watchedFiles = new Set<string>(); |
|
#refreshTimeouts = new Map<string, NodeJS.Timeout>(); |
|
#REFRESH_DELAY = 300; |
|
#storageChannel: BroadcastChannel; |
|
|
|
previews = atom<PreviewInfo[]>([]); |
|
|
|
constructor(webcontainerPromise: Promise<WebContainer>) { |
|
this.#webcontainer = webcontainerPromise; |
|
this.#broadcastChannel = new BroadcastChannel(PREVIEW_CHANNEL); |
|
this.#storageChannel = new BroadcastChannel('storage-sync-channel'); |
|
|
|
|
|
this.#broadcastChannel.onmessage = (event) => { |
|
const { type, previewId } = event.data; |
|
|
|
if (type === 'file-change') { |
|
const timestamp = event.data.timestamp; |
|
const lastUpdate = this.#lastUpdate.get(previewId) || 0; |
|
|
|
if (timestamp > lastUpdate) { |
|
this.#lastUpdate.set(previewId, timestamp); |
|
this.refreshPreview(previewId); |
|
} |
|
} |
|
}; |
|
|
|
|
|
this.#storageChannel.onmessage = (event) => { |
|
const { storage, source } = event.data; |
|
|
|
if (storage && source !== this._getTabId()) { |
|
this._syncStorage(storage); |
|
} |
|
}; |
|
|
|
|
|
if (typeof window !== 'undefined') { |
|
const originalSetItem = localStorage.setItem; |
|
|
|
localStorage.setItem = (...args) => { |
|
originalSetItem.apply(localStorage, args); |
|
this._broadcastStorageSync(); |
|
}; |
|
} |
|
|
|
this.#init(); |
|
} |
|
|
|
|
|
private _getTabId(): string { |
|
if (typeof window !== 'undefined') { |
|
if (!window._tabId) { |
|
window._tabId = Math.random().toString(36).substring(2, 15); |
|
} |
|
|
|
return window._tabId; |
|
} |
|
|
|
return ''; |
|
} |
|
|
|
|
|
private _syncStorage(storage: Record<string, string>) { |
|
if (typeof window !== 'undefined') { |
|
Object.entries(storage).forEach(([key, value]) => { |
|
try { |
|
const originalSetItem = Object.getPrototypeOf(localStorage).setItem; |
|
originalSetItem.call(localStorage, key, value); |
|
} catch (error) { |
|
console.error('[Preview] Error syncing storage:', error); |
|
} |
|
}); |
|
|
|
|
|
const previews = this.previews.get(); |
|
previews.forEach((preview) => { |
|
const previewId = this.getPreviewId(preview.baseUrl); |
|
|
|
if (previewId) { |
|
this.refreshPreview(previewId); |
|
} |
|
}); |
|
|
|
|
|
if (typeof window !== 'undefined' && window.location) { |
|
const iframe = document.querySelector('iframe'); |
|
|
|
if (iframe) { |
|
iframe.src = iframe.src; |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
private _broadcastStorageSync() { |
|
if (typeof window !== 'undefined') { |
|
const storage: Record<string, string> = {}; |
|
|
|
for (let i = 0; i < localStorage.length; i++) { |
|
const key = localStorage.key(i); |
|
|
|
if (key) { |
|
storage[key] = localStorage.getItem(key) || ''; |
|
} |
|
} |
|
|
|
this.#storageChannel.postMessage({ |
|
type: 'storage-sync', |
|
storage, |
|
source: this._getTabId(), |
|
timestamp: Date.now(), |
|
}); |
|
} |
|
} |
|
|
|
async #init() { |
|
const webcontainer = await this.#webcontainer; |
|
|
|
|
|
webcontainer.on('server-ready', (port, url) => { |
|
console.log('[Preview] Server ready on port:', port, url); |
|
this.broadcastUpdate(url); |
|
|
|
|
|
this._broadcastStorageSync(); |
|
}); |
|
|
|
try { |
|
|
|
const watcher = await webcontainer.fs.watch('**/*', { persistent: true }); |
|
|
|
|
|
(watcher as any).addEventListener('change', async () => { |
|
const previews = this.previews.get(); |
|
|
|
for (const preview of previews) { |
|
const previewId = this.getPreviewId(preview.baseUrl); |
|
|
|
if (previewId) { |
|
this.broadcastFileChange(previewId); |
|
} |
|
} |
|
}); |
|
|
|
|
|
if (typeof window !== 'undefined') { |
|
const observer = new MutationObserver((_mutations) => { |
|
|
|
this._broadcastStorageSync(); |
|
}); |
|
|
|
observer.observe(document.body, { |
|
childList: true, |
|
subtree: true, |
|
characterData: true, |
|
attributes: true, |
|
}); |
|
} |
|
} catch (error) { |
|
console.error('[Preview] Error setting up watchers:', error); |
|
} |
|
|
|
|
|
webcontainer.on('port', (port, type, url) => { |
|
let previewInfo = this.#availablePreviews.get(port); |
|
|
|
if (type === 'close' && previewInfo) { |
|
this.#availablePreviews.delete(port); |
|
this.previews.set(this.previews.get().filter((preview) => preview.port !== port)); |
|
|
|
return; |
|
} |
|
|
|
const previews = this.previews.get(); |
|
|
|
if (!previewInfo) { |
|
previewInfo = { port, ready: type === 'open', baseUrl: url }; |
|
this.#availablePreviews.set(port, previewInfo); |
|
previews.push(previewInfo); |
|
} |
|
|
|
previewInfo.ready = type === 'open'; |
|
previewInfo.baseUrl = url; |
|
|
|
this.previews.set([...previews]); |
|
|
|
if (type === 'open') { |
|
this.broadcastUpdate(url); |
|
} |
|
}); |
|
} |
|
|
|
|
|
getPreviewId(url: string): string | null { |
|
const match = url.match(/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/); |
|
return match ? match[1] : null; |
|
} |
|
|
|
|
|
broadcastStateChange(previewId: string) { |
|
const timestamp = Date.now(); |
|
this.#lastUpdate.set(previewId, timestamp); |
|
|
|
this.#broadcastChannel.postMessage({ |
|
type: 'state-change', |
|
previewId, |
|
timestamp, |
|
}); |
|
} |
|
|
|
|
|
broadcastFileChange(previewId: string) { |
|
const timestamp = Date.now(); |
|
this.#lastUpdate.set(previewId, timestamp); |
|
|
|
this.#broadcastChannel.postMessage({ |
|
type: 'file-change', |
|
previewId, |
|
timestamp, |
|
}); |
|
} |
|
|
|
|
|
broadcastUpdate(url: string) { |
|
const previewId = this.getPreviewId(url); |
|
|
|
if (previewId) { |
|
const timestamp = Date.now(); |
|
this.#lastUpdate.set(previewId, timestamp); |
|
|
|
this.#broadcastChannel.postMessage({ |
|
type: 'file-change', |
|
previewId, |
|
timestamp, |
|
}); |
|
} |
|
} |
|
|
|
|
|
refreshPreview(previewId: string) { |
|
|
|
const existingTimeout = this.#refreshTimeouts.get(previewId); |
|
|
|
if (existingTimeout) { |
|
clearTimeout(existingTimeout); |
|
} |
|
|
|
|
|
const timeout = setTimeout(() => { |
|
const previews = this.previews.get(); |
|
const preview = previews.find((p) => this.getPreviewId(p.baseUrl) === previewId); |
|
|
|
if (preview) { |
|
preview.ready = false; |
|
this.previews.set([...previews]); |
|
|
|
requestAnimationFrame(() => { |
|
preview.ready = true; |
|
this.previews.set([...previews]); |
|
}); |
|
} |
|
|
|
this.#refreshTimeouts.delete(previewId); |
|
}, this.#REFRESH_DELAY); |
|
|
|
this.#refreshTimeouts.set(previewId, timeout); |
|
} |
|
} |
|
|
|
|
|
let previewsStore: PreviewsStore | null = null; |
|
|
|
export function usePreviewStore() { |
|
if (!previewsStore) { |
|
|
|
|
|
|
|
|
|
previewsStore = new PreviewsStore(Promise.resolve({} as WebContainer)); |
|
} |
|
|
|
return previewsStore; |
|
} |
|
|