diff --git a/packages/vite-plugin/scripts/build-templates.js b/packages/vite-plugin/scripts/build-templates.js index 7723b32..24a357d 100644 --- a/packages/vite-plugin/scripts/build-templates.js +++ b/packages/vite-plugin/scripts/build-templates.js @@ -8,64 +8,202 @@ const __dirname = dirname(__filename); const projectRoot = join(__dirname, '..'); function buildTemplatesWithTsc() { - console.log('Compiling templates with TypeScript...'); + console.log('Building templates with bundling...'); + + // Build service worker with bundled dependencies + buildBundledServiceWorker(); + + // Build register template (no bundling needed) + buildRegisterTemplate(); +} + +function buildBundledServiceWorker() { + console.log('Building bundled service worker...'); + + const outputPath = join( + projectRoot, + 'src/generated/service-worker-template.ts', + ); + + // Create a single bundled TypeScript file first + const bundledTsPath = join( + projectRoot, + 'src/templates/bundled-service-worker.ts', + ); + createBundledTsFile(bundledTsPath); // Clean previous compilation execSync(`rm -rf ${join(projectRoot, 'src/generated-js')}`, { cwd: projectRoot, }); - // Compile all templates with unified config + // Compile the bundled file with TypeScript try { - console.log('Compiling templates...'); - execSync(`npx tsc --project tsconfig.templates.json`, { + execSync(`mkdir -p ${join(projectRoot, 'src/generated-js')}`, { cwd: projectRoot, - stdio: 'inherit', }); + + execSync( + `npx tsc ${bundledTsPath} --target ES2022 --module ESNext --outDir src/generated-js --lib ES2022,WebWorker,DOM --skipLibCheck`, + { + cwd: projectRoot, + stdio: 'inherit', + }, + ); } catch (error) { - console.error('Template TypeScript compilation failed:', error.message); + console.error('TypeScript compilation failed:', error.message); process.exit(1); } - // Read compiled JavaScript files and create template exports - processCompiledServiceWorker(); - processCompiledRegister(); + // Read compiled JavaScript file + const compiledPath = join( + projectRoot, + 'src/generated-js/bundled-service-worker.js', + ); + const jsContent = readFileSync(compiledPath, 'utf-8'); - // Clean up generated JS files + // Create the template export + const templateContent = `// Auto-generated template - do not edit manually +export const SW_TEMPLATE = ${JSON.stringify(jsContent)}; +`; + + writeFileSync(outputPath, templateContent); + + // Clean up temporary files + execSync(`rm -f ${bundledTsPath}`, { cwd: projectRoot }); execSync(`rm -rf ${join(projectRoot, 'src/generated-js')}`, { cwd: projectRoot, }); + + console.log(`✓ Generated: ${outputPath}`); } -function processCompiledServiceWorker() { - const compiledPath = join(projectRoot, 'src/generated-js/service-worker.js'); - const outputPath = join( - projectRoot, - 'src/generated/service-worker-template.ts', +function createBundledTsFile(outputPath) { + // Read all source files + const databaseContent = readFileSync( + join(projectRoot, 'src/templates/database.ts'), + 'utf-8', ); + const copilotContent = readFileSync( + join(projectRoot, 'src/templates/workerify-sw-copilot.ts'), + 'utf-8', + ); + + // Process the files to remove imports/exports and make them compatible + const processedDatabase = databaseContent.replace(/^export\s+/gm, ''); + const processedCopilot = copilotContent + .replace(/^import\s+.*?from\s+.*?;?\s*$/gm, '') + .replace(/^export\s+/gm, ''); + + // Create the bundled TypeScript file + const bundledContent = `// === Bundled Workerify Service Worker === +// This file is auto-generated - do not edit manually + +${processedDatabase} + +${processedCopilot} + +// Main service worker +console.log('[Workerify SW] Service worker script loaded'); +const { onClientsClaim, onFetch } = init(self as any); + +self.addEventListener('install', () => { + console.log('[Workerify SW] Installing'); + self.skipWaiting(); +}); + +self.addEventListener('activate', (e: ExtendableEvent) => { + console.log('[Workerify SW] Activating'); console.log( - `Processing compiled service worker: ${compiledPath} -> ${outputPath}`, + '[Workerify SW] Will start intercepting requests for scope:', + self.registration.scope, ); + e.waitUntil( + self.clients.claim().then(() => { + onClientsClaim(); + }), + ); +}); - const jsContent = readFileSync(compiledPath, 'utf-8'); - - // Create the template export - const templateContent = `// Auto-generated template - do not edit manually -export const SW_TEMPLATE = ${JSON.stringify(jsContent)}; -`; +// Test if fetch listener is working at all +console.log('[Workerify SW] Adding fetch event listener...'); +self.addEventListener('fetch', (event: FetchEvent) => { + event.respondWith( + (async () => { + const response = await onFetch(event); + if (response) { + return response; + } + // @ts-expect-error + return fetch(event); + })(), + ); +});`; - writeFileSync(outputPath, templateContent); - console.log(`✓ Generated: ${outputPath}`); + writeFileSync(outputPath, bundledContent); } -function processCompiledRegister() { - const compiledPath = join(projectRoot, 'src/generated-js/register.js'); +function buildRegisterTemplate() { + console.log('Building register template...'); + const outputPath = join(projectRoot, 'src/generated/register-template.ts'); + const registerContent = readFileSync( + join(projectRoot, 'src/templates/register.ts'), + 'utf-8', + ); - console.log(`Processing compiled register: ${compiledPath} -> ${outputPath}`); + // Create a single bundled TypeScript file first + const bundledTsPath = join(projectRoot, 'src/templates/bundled-register.ts'); - const jsContent = readFileSync(compiledPath, 'utf-8'); + // Remove imports but keep exports since they need to be preserved + const processedRegister = registerContent.replace( + /^import\s+.*?from\s+.*?;?\s*$/gm, + '', + ); + + const bundledContent = `// === Bundled Register Template === +// This file is auto-generated - do not edit manually + +${processedRegister}`; + + writeFileSync(bundledTsPath, bundledContent); + + // Clean previous compilation + execSync(`rm -rf ${join(projectRoot, 'src/generated-js')}`, { + cwd: projectRoot, + }); + + // Compile the bundled file with TypeScript + try { + execSync(`mkdir -p ${join(projectRoot, 'src/generated-js')}`, { + cwd: projectRoot, + }); + + execSync( + `npx tsc ${bundledTsPath} --target ES2022 --module ESNext --outDir src/generated-js --lib ES2022,DOM --skipLibCheck`, + { + cwd: projectRoot, + stdio: 'inherit', + }, + ); + } catch (error) { + console.error('TypeScript compilation failed:', error.message); + process.exit(1); + } + + // Read compiled JavaScript file + const compiledPath = join( + projectRoot, + 'src/generated-js/bundled-register.js', + ); + let jsContent = readFileSync(compiledPath, 'utf-8'); + + // Remove leading comments that cause Vite parsing issues + jsContent = jsContent + .replace(/^\/\/.*$/gm, '') // Remove single-line comments + .replace(/^\s*$/gm, '') // Remove empty lines + .replace(/^[\s\n]+/, ''); // Remove leading whitespace/newlines // Create a function that generates the register module with placeholder replacement const templateContent = `// Auto-generated template - do not edit manually @@ -80,6 +218,13 @@ export function getRegisterModule(publicSwUrl: string, scope: string, swFileName `; writeFileSync(outputPath, templateContent); + + // Clean up temporary files + execSync(`rm -f ${bundledTsPath}`, { cwd: projectRoot }); + execSync(`rm -rf ${join(projectRoot, 'src/generated-js')}`, { + cwd: projectRoot, + }); + console.log(`✓ Generated: ${outputPath}`); } @@ -88,7 +233,7 @@ execSync(`mkdir -p ${join(projectRoot, 'src/generated')}`, { cwd: projectRoot, }); -// Build templates using TypeScript compiler +// Build templates using bundling approach buildTemplatesWithTsc(); console.log('✓ All templates built successfully with tsc'); diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 4c4c875..2f0ca2d 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -27,6 +27,15 @@ export interface WorkerifyPluginOptions { swFileName?: string; } +export type { + ExtendableEvent, + FetchEvent, + Route, + ServiceWorkerGlobalScope, +} from './service-worker'; +// Re-export service worker functions for manual usage +export { init } from './service-worker'; + export default function workerifyPlugin( opts: WorkerifyPluginOptions = {}, ): Plugin { diff --git a/packages/vite-plugin/src/service-worker.ts b/packages/vite-plugin/src/service-worker.ts new file mode 100644 index 0000000..ac3260c --- /dev/null +++ b/packages/vite-plugin/src/service-worker.ts @@ -0,0 +1,20 @@ +export type { Route } from './templates/database'; +export { init } from './templates/workerify-sw-copilot'; + +// Re-export commonly used types for service worker development +export interface ServiceWorkerGlobalScope extends EventTarget { + registration: ServiceWorkerRegistration; + clients: Clients; + skipWaiting(): Promise; +} + +export interface FetchEvent extends ExtendableEvent { + request: Request; + clientId: string; + resultingClientId?: string; + respondWith(response: Promise | Response): void; +} + +export interface ExtendableEvent extends Event { + waitUntil(promise: Promise): void; +} diff --git a/packages/vite-plugin/src/templates/database.ts b/packages/vite-plugin/src/templates/database.ts new file mode 100644 index 0000000..a4e7170 --- /dev/null +++ b/packages/vite-plugin/src/templates/database.ts @@ -0,0 +1,100 @@ +export type Route = { method?: string; path: string; match?: string }; + +// IndexedDB configuration +const DB_NAME = 'workerify-sw-state'; +const DB_VERSION = 1; +const STORE_NAME = 'state'; + +// Initialize IndexedDB +async function initDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + }); +} + +// Save state to IndexedDB +export async function saveState({ + clientConsumerMap, + consumerRoutesMap, +}: { + clientConsumerMap: Map; + consumerRoutesMap: Map>; +}) { + try { + const db = await initDB(); + const tx = db.transaction([STORE_NAME], 'readwrite'); + const store = tx.objectStore(STORE_NAME); + + // Convert Maps to serializable arrays + const clientConsumerArray = Array.from(clientConsumerMap.entries()); + const consumerRoutesArray = Array.from(consumerRoutesMap.entries()); + + store.put(clientConsumerArray, 'clientConsumerMap'); + store.put(consumerRoutesArray, 'consumerRoutesMap'); + + await new Promise((resolve, reject) => { + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + + db.close(); + console.log('[Workerify SW] State saved to IndexedDB'); + } catch (error) { + console.error('[Workerify SW] Failed to save state:', error); + } +} + +// Load state from IndexedDB +export async function loadState(): Promise<{ + clientConsumerMap: Map; + consumerRoutesMap: Map; +}> { + try { + const db = await initDB(); + const tx = db.transaction([STORE_NAME], 'readonly'); + const store = tx.objectStore(STORE_NAME); + + const clientConsumerRequest = store.get('clientConsumerMap'); + const consumerRoutesRequest = store.get('consumerRoutesMap'); + + const [clientConsumerArray, consumerRoutesArray] = await Promise.all([ + new Promise<[string, string][]>((resolve) => { + clientConsumerRequest.onsuccess = () => + resolve(clientConsumerRequest.result || []); + }), + new Promise<[string, Array][]>((resolve) => { + consumerRoutesRequest.onsuccess = () => + resolve(consumerRoutesRequest.result || []); + }), + ]); + + // Restore Maps from arrays + const clientConsumerMap = new Map(clientConsumerArray); + const consumerRoutesMap = new Map(consumerRoutesArray); + + db.close(); + console.log('[Workerify SW] State loaded from IndexedDB'); + console.log('[Workerify SW] Restored clients:', clientConsumerMap.size); + console.log('[Workerify SW] Restored consumers:', consumerRoutesMap.size); + return { + clientConsumerMap, + consumerRoutesMap, + }; + } catch (error) { + console.error('[Workerify SW] Failed to load state:', error); + return { + clientConsumerMap: new Map(), + consumerRoutesMap: new Map(), + }; + } +} diff --git a/packages/vite-plugin/src/templates/service-worker.ts b/packages/vite-plugin/src/templates/service-worker.ts index 21d0e8e..cb1b378 100644 --- a/packages/vite-plugin/src/templates/service-worker.ts +++ b/packages/vite-plugin/src/templates/service-worker.ts @@ -1,205 +1,8 @@ // === Workerify SW === -// Body types (matching lib package) -type WorkerifyBody = ArrayBuffer | string | null | object; -type BodyType = 'json' | 'text' | 'arrayBuffer'; +import { init } from './workerify-sw-copilot'; -// HTTP response types -interface ResponseData { - status?: number; - statusText?: string; - headers?: Record; - body?: WorkerifyBody; - bodyType?: BodyType; -} - -interface WorkerifyResponse extends ResponseData { - type: string; - id?: string; -} console.log('[Workerify SW] Service worker script loaded'); -console.log('[Workerify SW] Current location:', self.location.href); -console.log('[Workerify SW] Scope:', self.registration?.scope); - -const CHANNEL = new BroadcastChannel('workerify'); -// Map of clientId -> consumerId -const clientConsumerMap = new Map(); -// Map of consumerId -> routes -const consumerRoutesMap = new Map< - string, - Array<{ method?: string; path: string; match?: string }> ->(); - -// IndexedDB configuration -const DB_NAME = 'workerify-sw-state'; -const DB_VERSION = 1; -const STORE_NAME = 'state'; - -// Initialize IndexedDB -async function initDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; - }); -} - -// Save state to IndexedDB -async function saveState() { - try { - const db = await initDB(); - const tx = db.transaction([STORE_NAME], 'readwrite'); - const store = tx.objectStore(STORE_NAME); - - // Convert Maps to serializable arrays - const clientConsumerArray = Array.from(clientConsumerMap.entries()); - const consumerRoutesArray = Array.from(consumerRoutesMap.entries()); - - store.put(clientConsumerArray, 'clientConsumerMap'); - store.put(consumerRoutesArray, 'consumerRoutesMap'); - - await new Promise((resolve, reject) => { - tx.oncomplete = resolve; - tx.onerror = () => reject(tx.error); - }); - - db.close(); - console.log('[Workerify SW] State saved to IndexedDB'); - } catch (error) { - console.error('[Workerify SW] Failed to save state:', error); - } -} - -// Load state from IndexedDB -async function loadState() { - try { - const db = await initDB(); - const tx = db.transaction([STORE_NAME], 'readonly'); - const store = tx.objectStore(STORE_NAME); - - const clientConsumerRequest = store.get('clientConsumerMap'); - const consumerRoutesRequest = store.get('consumerRoutesMap'); - - const [clientConsumerArray, consumerRoutesArray] = await Promise.all([ - new Promise<[string, string][]>((resolve) => { - clientConsumerRequest.onsuccess = () => - resolve(clientConsumerRequest.result || []); - }), - new Promise< - [string, Array<{ method?: string; path: string; match?: string }>][] - >((resolve) => { - consumerRoutesRequest.onsuccess = () => - resolve(consumerRoutesRequest.result || []); - }), - ]); - - // Restore Maps from arrays - clientConsumerMap.clear(); - clientConsumerArray.forEach(([key, value]) => { - clientConsumerMap.set(key, value); - }); - - consumerRoutesMap.clear(); - consumerRoutesArray.forEach(([key, value]) => { - consumerRoutesMap.set(key, value); - }); - - db.close(); - console.log('[Workerify SW] State loaded from IndexedDB'); - console.log('[Workerify SW] Restored clients:', clientConsumerMap.size); - console.log('[Workerify SW] Restored consumers:', consumerRoutesMap.size); - } catch (error) { - console.error('[Workerify SW] Failed to load state:', error); - } -} - -// Load state on service worker startup -loadState(); - -CHANNEL.onmessage = (ev) => { - const msg = ev.data; - if (msg?.type === 'workerify:sw:check-readiness') { - console.log('[Workerify SW] Check readiness:'); - - if (self.registration?.active?.state === 'activated') { - // Send acknowledgment - CHANNEL.postMessage({ - type: 'workerify:sw:check-readiness:response', - body: self.registration?.active?.state === 'activated', - }); - } - } - if (msg?.type === 'workerify:routes:update' && msg.consumerId) { - console.log( - '[Workerify SW] Updating routes for consumer:', - msg.consumerId, - msg.routes, - ); - consumerRoutesMap.set(msg.consumerId, msg.routes || []); - - // Save state to IndexedDB - saveState(); - - // Send acknowledgment - CHANNEL.postMessage({ - type: 'workerify:routes:update:response', - consumerId: msg.consumerId, - }); - } - - if (msg?.type === 'workerify:routes:list') { - console.log('[Workerify SW] Active consumers and their routes:'); - consumerRoutesMap.forEach((routes, consumerId) => { - console.log(`Consumer: ${consumerId}`); - console.table(routes.map((r) => [r.method, r.path, r.match])); - }); - } - - if (msg?.type === 'workerify:routes:clear') { - consumerRoutesMap.clear(); - clientConsumerMap.clear(); - // Save cleared state to IndexedDB - saveState(); - } - - if (msg?.type === 'workerify:clients:list') { - console.log('[Workerify SW] Listing all clients...'); - self.clients - .matchAll({ includeUncontrolled: true }) - .then((clients: readonly Client[]) => { - console.log('[Workerify SW] Total clients found:', clients.length); - clients.forEach((client: Client, i: number) => { - console.log(`[Workerify SW] Client ${i + 1}:`, { - id: client.id, - url: client.url, - type: client.type, - frameType: client.frameType, - }); - }); - - console.log('[Workerify SW] Client-Consumer mappings:'); - console.table( - Array.from(clientConsumerMap.entries()).map( - ([clientId, consumerId]) => ({ - clientId, - consumerId, - hasActiveClient: clients.some((c) => c.id === clientId), - }), - ), - ); - }) - .catch((error: unknown) => { - console.error('[Workerify SW] Error listing clients:', error); - }); - } -}; +const { onClientsClaim, onFetch } = init(self); self.addEventListener('install', () => { console.log('[Workerify SW] Installing'); @@ -214,306 +17,22 @@ self.addEventListener('activate', (e: ExtendableEvent) => { ); e.waitUntil( self.clients.claim().then(() => { - console.log('[Workerify SW] Now controlling all clients'); - console.log( - '[Workerify SW] Notify SW is ready', - self.registration?.active?.state, - ); - // Send acknowledgment - CHANNEL.postMessage({ - type: 'workerify:sw:check-readiness:response', - body: self.registration?.active?.state === 'activated', - }); - // Clean up mappings for closed clients - cleanupClosedClients(); + onClientsClaim(); }), ); }); -// Periodically clean up mappings for closed clients -setInterval(() => { - cleanupClosedClients(); -}, 30000); // Every 30 seconds - -async function cleanupClosedClients() { - try { - const allClients = await self.clients.matchAll({ - includeUncontrolled: true, - }); - const activeClientIds = new Set(allClients.map((c) => c.id)); - - // Remove mappings for clients that no longer exist - const toRemove: string[] = []; - clientConsumerMap.forEach((consumerId, clientId) => { - if (!activeClientIds.has(clientId)) { - toRemove.push(clientId); - // Also clean up routes if no other clients use this consumer - const hasOtherClients = - Array.from(clientConsumerMap.values()).filter( - (cid) => cid === consumerId, - ).length > 1; - if (!hasOtherClients) { - consumerRoutesMap.delete(consumerId); - console.log('[Workerify SW] Cleaned up consumer:', consumerId); - } - } - }); - - toRemove.forEach((clientId) => { - clientConsumerMap.delete(clientId); - console.log('[Workerify SW] Cleaned up client:', clientId); - }); - - // Save state after cleanup - if (toRemove.length > 0) { - saveState(); - } - } catch (error) { - console.error('[Workerify SW] Cleanup error:', error); - } -} - -function matchRoute( - url: string, - method: string, - routes: Array<{ method?: string; path: string; match?: string }>, -) { - const u = new URL(url); - const pathname = u.pathname; - method = method.toUpperCase(); - - for (const r of routes) { - if (r.method && r.method !== method) continue; - - if ((r.match ?? 'exact') === 'prefix') { - if (pathname.startsWith(r.path)) return r; - } else { - // Check for exact match or parameterized route - if (pathname === r.path) return r; - - // Check if route has parameters - if (r.path.includes(':')) { - const routeParts = r.path.split('/'); - const pathParts = pathname.split('/'); - - // If different number of segments, no match - if (routeParts.length !== pathParts.length) continue; - - let isMatch = true; - for (let i = 0; i < routeParts.length; i++) { - const routePart = routeParts[i]; - const pathPart = pathParts[i]; - - // Skip parameter parts (they always match) - if (routePart.startsWith(':')) continue; - - // Static parts must match exactly - if (routePart !== pathPart) { - isMatch = false; - break; - } - } - - if (isMatch) return r; - } - } - } - return null; -} - -const pending = new Map< - string, - { resolve: (value: WorkerifyResponse) => void } ->(); - -CHANNEL.addEventListener('message', (ev) => { - const m = ev.data; - if (m?.type === 'SKIP_WAITING') { - self.skipWaiting(); - } - if (m?.type === 'workerify:response' && m.id && pending.has(m.id)) { - const resolver = pending.get(m.id); - if (resolver) { - resolver.resolve(m); - pending.delete(m.id); - } - } -}); - // Test if fetch listener is working at all console.log('[Workerify SW] Adding fetch event listener...'); - self.addEventListener('fetch', (event: FetchEvent) => { event.respondWith( (async () => { - const { request } = event; - const url = new URL(request.url); - - // We reload the database here because in some cases (like FF which kill the SW quickly for inactive) - // this the first call when the SW wakeup without runtime all the lifecycle, so everything is empty - if (clientConsumerMap.entries.length === 0) { - await loadState(); + const response = await onFetch(event); + if (response) { + return response; } - - // Handle registration endpoint - if ( - url.pathname === '/__workerify/register' && - request.method === 'POST' - ) { - console.log('[Workerify SW] Register new consumer'); - return handleRegistration(event); - } - - // Get the consumer ID for this client - const clientId = - event.clientId || - (event as FetchEvent & { resultingClientId?: string }) - .resultingClientId; - const consumerId = clientId ? clientConsumerMap.get(clientId) : undefined; - - if (!consumerId) { - // No consumer registered for this client - return fetch(request); - } - - // Get routes for this consumer - const routes = consumerRoutesMap.get(consumerId) || []; - const hit = matchRoute(request.url, request.method, routes); - - if (!hit) { - return fetch(request); - } - - return handle(event, consumerId); + // @ts-expect-error + return fetch(event); })(), ); }); - -async function handleRegistration(event: FetchEvent): Promise { - try { - const data = await event.request.json(); - const { consumerId } = data; - const clientId = - event.clientId || - (event as FetchEvent & { resultingClientId?: string }).resultingClientId; - - if (!clientId || !consumerId) { - return new Response(JSON.stringify({ error: 'Invalid registration' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Check if there's already a consumer for this client and remove it - const existingConsumer = clientConsumerMap.get(clientId); - if (existingConsumer) { - console.log( - '[Workerify SW] Replacing consumer for client:', - clientId, - 'old:', - existingConsumer, - 'new:', - consumerId, - ); - } - - // Set the new mapping - clientConsumerMap.set(clientId, consumerId); - - // Save state to IndexedDB - saveState(); - - console.log( - '[Workerify SW] Registered consumer:', - consumerId, - 'for client:', - clientId, - ); - - return new Response(JSON.stringify({ clientId, consumerId }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } catch (error) { - console.error('[Workerify SW] Registration error:', error); - return new Response(JSON.stringify({ error: 'Registration failed' }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); - } -} - -console.log('[Workerify SW] Fetch event listener added'); - -// Debug service worker state -console.log( - '[Workerify SW] Service worker state:', - self.registration?.active?.state, -); -console.log('[Workerify SW] Is controlling clients?', self.clients); - -// Test if we can see clients -self.clients.matchAll().then((clients: readonly Client[]) => { - console.log('[Workerify SW] Current clients:', clients.length); - clients.forEach((client: Client, i: number) => { - console.log(`[Workerify SW] Client ${i}:`, client.url); - }); -}); - -async function handle( - event: FetchEvent, - consumerId: string, -): Promise { - const req = event.request; - const id = Math.random().toString(36).slice(2); - const headers: Record = {}; - req.headers.forEach((v, k) => { - headers[k] = v; - }); - const body = - req.method === 'GET' || req.method === 'HEAD' - ? null - : await req.arrayBuffer(); - - const p = new Promise((resolve) => pending.set(id, { resolve })); - CHANNEL.postMessage({ - type: 'workerify:handle', - id, - consumerId, - request: { - url: req.url, - method: req.method, - headers, - body, - }, - }); - - const resp = (await p) as WorkerifyResponse; - - const h = new Headers(resp.headers || {}); - const init: ResponseInit = { - status: resp.status || 200, - statusText: resp.statusText || '', - headers: h, - }; - - if (resp.body && resp.bodyType === 'arrayBuffer') { - return new Response(resp.body as ArrayBuffer, init); - } - - if (resp.body && resp.bodyType === 'json') { - h.set('content-type', h.get('content-type') || 'application/json'); - return new Response(JSON.stringify(resp.body), init); - } - - return new Response( - typeof resp.body === 'string' - ? resp.body - : resp.body - ? JSON.stringify(resp.body) - : '', - init, - ); -} - -// === End Workerify SW === diff --git a/packages/vite-plugin/src/templates/workerify-sw-copilot.ts b/packages/vite-plugin/src/templates/workerify-sw-copilot.ts new file mode 100644 index 0000000..4c74132 --- /dev/null +++ b/packages/vite-plugin/src/templates/workerify-sw-copilot.ts @@ -0,0 +1,421 @@ +import { loadState, type Route, saveState } from './database'; + +type WorkerifyBody = ArrayBuffer | string | null | object; +type BodyType = 'json' | 'text' | 'arrayBuffer'; + +// HTTP response types +interface ResponseData { + status?: number; + statusText?: string; + headers?: Record; + body?: WorkerifyBody; + bodyType?: BodyType; +} + +interface WorkerifyResponse extends ResponseData { + type: string; + id?: string; +} + +export function init(self: ServiceWorkerGlobalScope) { + console.log('[Workerify SW] Init Workerify SW Copilot'); + console.log('[Workerify SW] Current location:', self.location.href); + console.log('[Workerify SW] Scope:', self.registration?.scope); + + const workerifyBC = new BroadcastChannel('workerify'); + + const pending = new Map< + string, + { resolve: (value: WorkerifyResponse) => void } + >(); + let consumerRoutesMap = new Map(); + let clientConsumerMap = new Map(); + loadState().then((state) => { + consumerRoutesMap = state.consumerRoutesMap; + clientConsumerMap = state.clientConsumerMap; + }); + + async function cleanupClosedClients() { + try { + const allClients = await self.clients.matchAll({ + includeUncontrolled: true, + }); + const activeClientIds = new Set( + allClients.map((c: { id: string }) => c.id), + ); + + // Remove mappings for clients that no longer exist + const toRemove: string[] = []; + clientConsumerMap.forEach((consumerId, clientId) => { + if (!activeClientIds.has(clientId)) { + toRemove.push(clientId); + // Also clean up routes if no other clients use this consumer + const hasOtherClients = + Array.from(clientConsumerMap.values()).filter( + (cid) => cid === consumerId, + ).length > 1; + if (!hasOtherClients) { + consumerRoutesMap.delete(consumerId); + console.log('[Workerify SW] Cleaned up consumer:', consumerId); + } + } + }); + + toRemove.forEach((clientId) => { + clientConsumerMap.delete(clientId); + console.log('[Workerify SW] Cleaned up client:', clientId); + }); + + // Save state after cleanup + if (toRemove.length > 0) { + await saveState({ + consumerRoutesMap, + clientConsumerMap, + }); + } + } catch (error) { + console.error('[Workerify SW] Cleanup error:', error); + } + } + + setInterval(() => { + cleanupClosedClients(); + }, 30000); // Every 30 seconds + + const onClientsClaim = () => { + console.log('[Workerify SW] Now controlling all clients'); + console.log( + '[Workerify SW] Notify SW is ready', + self.registration?.active?.state, + ); + // Send acknowledgment + workerifyBC.postMessage({ + type: 'workerify:sw:check-readiness:response', + body: self.registration?.active?.state === 'activated', + }); + // Clean up mappings for closed clients + cleanupClosedClients(); + }; + + function matchRoute( + url: string, + method: string, + routes: Array<{ method?: string; path: string; match?: string }>, + ) { + const u = new URL(url); + const pathname = u.pathname; + method = method.toUpperCase(); + + for (const r of routes) { + if (r.method && r.method !== method) continue; + + if ((r.match ?? 'exact') === 'prefix') { + if (pathname.startsWith(r.path)) return r; + } else { + // Check for exact match or parameterized route + if (pathname === r.path) return r; + + // Check if route has parameters + if (r.path.includes(':')) { + const routeParts = r.path.split('/'); + const pathParts = pathname.split('/'); + + // If different number of segments, no match + if (routeParts.length !== pathParts.length) continue; + + let isMatch = true; + for (let i = 0; i < routeParts.length; i++) { + const routePart = routeParts[i]; + const pathPart = pathParts[i]; + + // Skip parameter parts (they always match) + if (routePart?.startsWith(':')) continue; + + // Static parts must match exactly + if (routePart !== pathPart) { + isMatch = false; + break; + } + } + + if (isMatch) return r; + } + } + } + return null; + } + + async function handleRegistration(event: FetchEvent): Promise { + try { + const data = await event.request.json(); + const { consumerId } = data; + const clientId = + event.clientId || + (event as FetchEvent & { resultingClientId?: string }) + .resultingClientId; + + if (!clientId || !consumerId) { + return new Response(JSON.stringify({ error: 'Invalid registration' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check if there's already a consumer for this client and remove it + const existingConsumer = clientConsumerMap.get(clientId); + if (existingConsumer) { + console.log( + '[Workerify SW] Replacing consumer for client:', + clientId, + 'old:', + existingConsumer, + 'new:', + consumerId, + ); + } + + // Set the new mapping + clientConsumerMap.set(clientId, consumerId); + + // Save state to IndexedDB + await saveState({ + consumerRoutesMap, + clientConsumerMap, + }); + + console.log( + '[Workerify SW] Registered consumer:', + consumerId, + 'for client:', + clientId, + ); + + return new Response(JSON.stringify({ clientId, consumerId }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('[Workerify SW] Registration error:', error); + return new Response(JSON.stringify({ error: 'Registration failed' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + async function handleFetch( + event: FetchEvent, + consumerId: string, + ): Promise { + const req = event.request; + const id = Math.random().toString(36).slice(2); + const headers: Record = {}; + req.headers.forEach((v, k) => { + headers[k] = v; + }); + const body = + req.method === 'GET' || req.method === 'HEAD' + ? null + : await req.arrayBuffer(); + + const p = new Promise((resolve) => pending.set(id, { resolve })); + workerifyBC.postMessage({ + type: 'workerify:handle', + id, + consumerId, + request: { + url: req.url, + method: req.method, + headers, + body, + }, + }); + + const resp = (await p) as WorkerifyResponse; + + const h = new Headers(resp.headers || {}); + const init: ResponseInit = { + status: resp.status || 200, + statusText: resp.statusText || '', + headers: h, + }; + + if (resp.body && resp.bodyType === 'arrayBuffer') { + return new Response(resp.body as ArrayBuffer, init); + } + + if (resp.body && resp.bodyType === 'json') { + h.set('content-type', h.get('content-type') || 'application/json'); + return new Response(JSON.stringify(resp.body), init); + } + + return new Response( + typeof resp.body === 'string' + ? resp.body + : resp.body + ? JSON.stringify(resp.body) + : '', + init, + ); + } + + const onFetch = async (event: FetchEvent): Promise => { + const { request } = event; + const url = new URL(request.url); + + // We reload the database here because in some cases (like FF which kill the SW quickly for inactive) + // this the first call when the SW wakeup without runtime all the lifecycle, so everything is empty + if (Array.from(clientConsumerMap.entries()).length === 0) { + const newState = await loadState(); + consumerRoutesMap = newState.consumerRoutesMap; + clientConsumerMap = newState.clientConsumerMap; + } + + // Handle registration endpoint + if (url.pathname === '/__workerify/register' && request.method === 'POST') { + console.log('[Workerify SW] Register new consumer'); + return handleRegistration(event); + } + + // Get the consumer ID for this client + const clientId = + event.clientId || + (event as FetchEvent & { resultingClientId?: string }).resultingClientId; + const consumerId = clientId ? clientConsumerMap.get(clientId) : undefined; + + if (!consumerId) { + // No consumer registered for this client + return fetch(request); + } + + // Get routes for this consumer + const routes = consumerRoutesMap.get(consumerId) || []; + const hit = matchRoute(request.url, request.method, routes); + + if (!hit) { + return fetch(request); + } + + return handleFetch(event, consumerId); + }; + + workerifyBC.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + if (msg?.type === 'workerify:sw:check-readiness') { + console.log('[Workerify SW] Check readiness:'); + + if (self.registration?.active?.state === 'activated') { + // Send acknowledgment + workerifyBC.postMessage({ + type: 'workerify:sw:check-readiness:response', + body: self.registration?.active?.state === 'activated', + }); + } + } + if (msg?.type === 'workerify:routes:update' && msg.consumerId) { + console.log( + '[Workerify SW] Updating routes for consumer:', + msg.consumerId, + msg.routes, + ); + consumerRoutesMap.set(msg.consumerId, msg.routes || []); + + // Save state to IndexedDB + saveState({ + consumerRoutesMap, + clientConsumerMap, + }); + + // Send acknowledgment + workerifyBC.postMessage({ + type: 'workerify:routes:update:response', + consumerId: msg.consumerId, + }); + } + + if (msg?.type === 'workerify:routes:list') { + console.log('[Workerify SW] Active consumers and their routes:'); + consumerRoutesMap.forEach((routes, consumerId) => { + console.log(`Consumer: ${consumerId}`); + console.table(routes.map((r) => [r.method, r.path, r.match])); + }); + } + + if (msg?.type === 'workerify:routes:clear') { + consumerRoutesMap.clear(); + clientConsumerMap.clear(); + // Save cleared state to IndexedDB + saveState({ + clientConsumerMap, + consumerRoutesMap, + }); + } + + if (msg?.type === 'workerify:clients:list') { + console.log('[Workerify SW] Listing all clients...'); + self.clients + .matchAll({ includeUncontrolled: true }) + .then((clients: readonly Client[]) => { + console.log('[Workerify SW] Total clients found:', clients.length); + clients.forEach((client: Client, i: number) => { + console.log(`[Workerify SW] Client ${i + 1}:`, { + id: client.id, + url: client.url, + type: client.type, + frameType: client.frameType, + }); + }); + + console.log('[Workerify SW] Client-Consumer mappings:'); + console.table( + Array.from(clientConsumerMap.entries()).map( + ([clientId, consumerId]) => ({ + clientId, + consumerId, + hasActiveClient: clients.some((c) => c.id === clientId), + }), + ), + ); + }) + .catch((error: unknown) => { + console.error('[Workerify SW] Error listing clients:', error); + }); + } + }; + + workerifyBC.addEventListener('message', (ev: MessageEvent) => { + const m = ev.data; + if (m?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + if (m?.type === 'workerify:response' && m.id && pending.has(m.id)) { + const resolver = pending.get(m.id); + if (resolver) { + resolver.resolve(m); + pending.delete(m.id); + } + } + }); + + console.log('[Workerify SW] Fetch event listener added'); + + // Debug service worker state + console.log( + '[Workerify SW] Service worker state:', + self.registration?.active?.state, + ); + console.log('[Workerify SW] Is controlling clients?', self.clients); + + // Test if we can see clients + self.clients.matchAll().then((clients: readonly Client[]) => { + console.log('[Workerify SW] Current clients:', clients.length); + clients.forEach((client: Client, i: number) => { + console.log(`[Workerify SW] Client ${i}:`, client.url); + }); + }); + + return { + onClientsClaim, + onFetch, + }; +}