@skybolt/vite-plugin
Version:
Vite plugin for Skybolt - High-performance asset caching for multi-page applications
388 lines (335 loc) • 11.3 kB
JavaScript
/*! Skybolt - @version 3.5.1 */
/**
* Registers Service Worker and coordinates cache state via cookies.
* Extracts inlined assets and stores them in Cache API.
*/
import { CuckooFilter } from './cache-digest.js'
const CACHE_NAME = 'skybolt-v1'
const COOKIE_NAME = 'sb_digest'
const COOKIE_MAX_AGE = 31536000 // 1 year
// ============================================================================
// Skybolt Client
// ============================================================================
class SkyboltClient {
constructor() {
/** @type {Record<string, string>} Asset name -> hash */
this.versions = {}
/** @type {ServiceWorkerRegistration|null} */
this.registration = null
/** @type {{swPath: string}} */
this.config = null
this.init()
}
/**
* Initialize Skybolt client
*/
async init() {
// Check for Service Worker support
if (!('serviceWorker' in navigator)) {
console.warn('[Skybolt] Service Workers not supported')
this.handleNoServiceWorker()
return
}
// Check for forced disable via query param (for debugging)
if (new URLSearchParams(location.search).has('no-sw')) {
console.warn('[Skybolt] Service Worker disabled via ?no-sw')
this.handleNoServiceWorker()
return
}
// Register Service Worker
try {
this.config = this.loadConfig()
this.registration = await navigator.serviceWorker.register(this.config.swPath, {
scope: '/'
})
// Wait for SW to be ready
await navigator.serviceWorker.ready
console.log('[Skybolt] Service Worker ready')
} catch (err) {
console.error('[Skybolt] Service Worker registration failed:', err)
this.handleNoServiceWorker()
return
}
// Process any inlined assets on the page, then finalize
// We need to wait for DOM to be ready so we can find all sb-asset elements
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.processAndFinalize())
} else {
this.processAndFinalize()
}
}
/**
* Process inlined assets and update cookie
* Must wait for cache operations to complete before updating cookie
*/
async processAndFinalize() {
await this.processInlinedAssets()
await this.finalize()
}
/**
* Load configuration from meta tag
* @returns {{swPath: string}}
*/
loadConfig() {
const defaults = { swPath: '/skybolt-sw.js' }
const meta = document.querySelector('meta[name="skybolt-config"]')
if (meta) {
try {
return { ...defaults, ...JSON.parse(meta.content) }
} catch (err) {
console.warn('[Skybolt] Invalid config meta tag')
}
}
return defaults
}
/**
* Process all inlined assets and cache them
* @returns {Promise<void>}
*/
async processInlinedAssets() {
const elements = document.querySelectorAll('[sb-asset]')
console.log(`[Skybolt] Found ${elements.length} assets to cache`)
const cachePromises = []
elements.forEach(el => {
const cacheInfo = el.getAttribute('sb-asset')
const url = el.getAttribute('sb-url')
if (!cacheInfo || !url) {
console.warn('[Skybolt] Invalid cache attributes on element:', el)
return
}
// Parse "name:hash" format
const colonIndex = cacheInfo.lastIndexOf(':')
if (colonIndex === -1) {
console.warn('[Skybolt] Invalid cache info format:', cacheInfo)
return
}
const name = cacheInfo.substring(0, colonIndex)
const hash = cacheInfo.substring(colonIndex + 1)
// Track version for cookie
this.versions[name] = hash
// Handle importmap elements specially - extract data URL content
if (el.type === 'importmap') {
cachePromises.push(this.processImportMapAsset(el, url, name, hash))
return
}
// Get content and content type for regular elements
const content = el.textContent || ''
const contentType = el.tagName === 'STYLE' ? 'text/css' : 'application/javascript'
// Cache the asset
cachePromises.push(this.cacheAsset(url, content, contentType, name, hash))
})
// Wait for all cache operations to complete
await Promise.all(cachePromises)
}
/**
* Process an importmap element with sb-asset attributes
* Extracts the data URL content and caches the decoded JavaScript
* @param {HTMLScriptElement} el - The importmap script element
* @param {string} url - Cache URL
* @param {string} name - Asset name
* @param {string} hash - Content hash
* @returns {Promise<void>}
*/
async processImportMapAsset(el, url, name, hash) {
try {
const importMap = JSON.parse(el.textContent || '{}')
// Skybolt importmaps contain exactly one entry
const dataUrl = Object.values(importMap.imports || {})[0]
if (!dataUrl?.startsWith('data:application/javascript;base64,')) {
console.warn('[Skybolt] Invalid importmap data URL:', name)
return
}
// Decode the base64 content and cache
const base64 = dataUrl.slice('data:application/javascript;base64,'.length)
await this.cacheAsset(url, atob(base64), 'application/javascript', name, hash)
} catch (err) {
console.error('[Skybolt] Failed to process importmap:', name, err)
}
}
/**
* Cache an asset in the Service Worker cache
*
* @param {string} url - URL to use as cache key
* @param {string} content - Asset content
* @param {string} contentType - MIME type
* @param {string} name - Asset name (source path)
* @param {string} hash - Content hash
*/
async cacheAsset(url, content, contentType, name, hash) {
try {
const cache = await caches.open(CACHE_NAME)
const response = new Response(content, {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': contentType,
'Content-Length': String(new Blob([content]).size),
'X-Skybolt-Name': name,
'X-Skybolt-Hash': hash,
'Cache-Control': 'public, max-age=31536000, immutable'
}
})
await cache.put(url, response)
console.log(`[Skybolt] Cached: ${name}`)
} catch (err) {
console.error(`[Skybolt] Failed to cache ${name}:`, err)
}
}
/**
* Finalize initialization - update cookie with cached versions
*/
async finalize() {
const count = Object.keys(this.versions).length
if (count > 0) {
await this.updateCookieFromCache()
console.log(`[Skybolt] Ready (${count} new assets cached)`)
} else {
// No new assets to cache - validate existing cache integrity
this.validateCache()
}
}
/**
* Update cookie based on actual Cache API contents
* Uses Cache Digest (Cuckoo filter) for compact storage
*/
async updateCookieFromCache() {
try {
const cache = await caches.open(CACHE_NAME)
const keys = await cache.keys()
const versions = {}
// Read metadata from each cached response
for (const request of keys) {
const response = await cache.match(request)
if (response) {
const name = response.headers.get('X-Skybolt-Name')
const hash = response.headers.get('X-Skybolt-Hash')
if (name && hash) {
versions[name] = hash
}
}
}
const entries = Object.entries(versions)
if (entries.length === 0) return
// Write Cache Digest cookie (compact Cuckoo filter)
const filter = new CuckooFilter(entries.length)
for (const [name, hash] of entries) {
filter.insert(`${name}:${hash}`)
}
const digest = filter.toBase64()
document.cookie = `${COOKIE_NAME}=${digest}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
console.log(`[Skybolt] Cache state stored (${digest.length} bytes, ${entries.length} assets)`)
} catch (err) {
console.error('[Skybolt] Failed to update cookie from cache:', err)
}
}
/**
* Validate cache integrity when no new assets to cache
* Handles case where cache was cleared but cookies remain
*/
async validateCache() {
// Check if we have cookie data
const hasCookie = document.cookie.includes(COOKIE_NAME)
if (!hasCookie) return
try {
const cache = await caches.open(CACHE_NAME)
const keys = await cache.keys()
if (keys.length === 0) {
// Cookie exists but cache is empty - mismatch
console.warn('[Skybolt] Cache/cookie mismatch detected, clearing cookies')
this.clearCookies()
location.reload()
}
} catch (err) {
console.error('[Skybolt] Cache validation failed:', err)
}
}
/**
* Handle case where Service Worker is unavailable
* Clear cookies so next visit will inline assets again
*/
handleNoServiceWorker() {
console.warn('[Skybolt] Service Worker unavailable, clearing cookies')
this.clearCookies()
}
/**
* Clear all Skybolt cookies
*/
clearCookies() {
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0`
}
/**
* Clear cache (public API for debugging)
* Clears Cache API but keeps Service Worker registered
*/
async clearCache() {
try {
await caches.delete(CACHE_NAME)
this.clearCookies()
console.log('[Skybolt] Cache cleared')
} catch (err) {
console.error('[Skybolt] Failed to clear cache:', err)
}
}
/**
* Full reset (public API for debugging)
* Clears cache, unregisters Service Worker, reloads page
*/
async selfDestruct(reload=true) {
console.warn('[Skybolt] Self-destruct initiated')
// Clear cache
try {
await caches.delete(CACHE_NAME)
} catch (err) {
// Ignore
}
// Clear cookies
this.clearCookies()
// Unregister Service Workers
try {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(r => r.unregister()))
} catch (err) {
// Ignore
}
// Reload
if (reload) { location.reload() }
}
/**
* Get cache information (public API for debugging)
* @returns {Promise<{name: string, count: number, urls: string[]}>}
*/
async getCacheInfo() {
try {
const cache = await caches.open(CACHE_NAME)
const keys = await cache.keys()
return {
name: CACHE_NAME,
count: keys.length,
urls: keys.map(r => r.url)
}
} catch (err) {
return { name: CACHE_NAME, count: 0, urls: [], error: err.message }
}
}
/**
* Check if a URL is cached (public API for integrations like Chain Lightning)
* Uses the versions tracked during this page load
* @param {string} url - URL to check (can be relative or absolute)
* @returns {boolean} True if URL hash matches a cached asset
*/
isCachedUrl(url) {
// Check if URL contains any hash we've cached this session
for (const hash of Object.values(this.versions)) {
if (url.includes(hash)) {
return true
}
}
return false
}
}
// Auto-initialize and expose globally
const skybolt = new SkyboltClient()
if (typeof window !== 'undefined') {
window.skybolt = skybolt
}
export default skybolt