diff --git a/.github/workflows/test-latest-vite.yml b/.github/workflows/test-latest-vite.yml index a33884c..4096100 100644 --- a/.github/workflows/test-latest-vite.yml +++ b/.github/workflows/test-latest-vite.yml @@ -42,10 +42,12 @@ jobs: - name: Install Playwright browsers working-directory: test-minimal-example - run: npx playwright install chromium + run: npx playwright install chromium firefox - name: Run tests working-directory: test-minimal-example + env: + CI: true run: npm test - name: Report Vite version diff --git a/src/index.ts b/src/index.ts index 769ebd2..94e7fd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,21 +3,47 @@ import MagicString from "magic-string"; import { Plugin, normalizePath } from "vite"; import { SourceMapConsumer, SourceMapGenerator } from "source-map"; +// Template string to avoid static analysis issues with import.meta.url const importMetaUrl = `${"import"}.meta.url`; + +// Virtual module prefixes for identifying Comlink worker modules const urlPrefix_normal = "internal:comlink:"; const urlPrefix_shared = "internal:comlink-shared:"; +// Global state to track build mode and project root +// These are set during Vite's config resolution phase let mode = ""; let root = ""; +/** + * Vite plugin that automatically integrates Comlink with WebWorkers and SharedWorkers. + * + * This plugin transforms ComlinkWorker and ComlinkSharedWorker constructor calls + * to regular Worker/SharedWorker instances wrapped with Comlink's expose/wrap functionality. + * + * @returns Array of Vite plugins (currently contains only one plugin) + */ export function comlink(): Plugin[] { return [ { + /** + * Store Vite configuration values for later use in transformations + */ configResolved(conf) { mode = conf.mode; root = conf.root; }, name: "comlink", + + /** + * Resolve virtual module IDs for Comlink worker wrappers. + * + * When a ComlinkWorker/ComlinkSharedWorker is detected, we create virtual modules + * with special prefixes that contain the Comlink setup code. + * + * @param id - Module ID to resolve + * @returns Resolved ID if it's a Comlink virtual module, undefined otherwise + */ resolveId(id) { if (id.includes(urlPrefix_normal)) { return urlPrefix_normal + id.split(urlPrefix_normal)[1]; @@ -26,10 +52,22 @@ export function comlink(): Plugin[] { return urlPrefix_shared + id.split(urlPrefix_shared)[1]; } }, + /** + * Load virtual modules that contain Comlink worker setup code. + * + * This creates wrapper modules that automatically call Comlink's expose() + * function with the worker's exported API. + * + * @param id - Module ID to load + * @returns Generated module code for Comlink setup, or undefined + */ async load(id) { if (id.includes(urlPrefix_normal)) { + // Extract the real worker file path from the virtual module ID const realID = normalizePath(id.replace(urlPrefix_normal, "")); + // Generate wrapper code for regular Workers + // This imports the worker's API and exposes it through Comlink return ` import {expose} from 'comlink' import * as api from '${normalizePath(realID)}' @@ -39,8 +77,11 @@ export function comlink(): Plugin[] { } if (id.includes(urlPrefix_shared)) { + // Extract the real worker file path from the virtual module ID const realID = normalizePath(id.replace(urlPrefix_shared, "")); + // Generate wrapper code for SharedWorkers + // SharedWorkers need to handle the 'connect' event and expose on each port return ` import {expose} from 'comlink' import * as api from '${normalizePath(realID)}' @@ -49,19 +90,35 @@ export function comlink(): Plugin[] { const port = event.ports[0]; expose(api, port); - // We might need this later... - // port.start() + // Note: port.start() is typically not needed as expose() handles this }) `; } }, + /** + * Transform source code to replace ComlinkWorker/ComlinkSharedWorker constructors. + * + * This is the core transformation that: + * 1. Finds ComlinkWorker/ComlinkSharedWorker constructor calls + * 2. Extracts the worker URL and options + * 3. Replaces them with regular Worker/SharedWorker constructors + * 4. Wraps the result with Comlink's wrap() function + * 5. Redirects to virtual modules for automatic Comlink setup + * + * @param code - Source code to transform + * @param id - File ID being transformed + * @returns Transformed code with source maps, or undefined if no changes needed + */ async transform(code: string, id: string) { + // Early exit if file doesn't contain Comlink worker constructors if ( !code.includes("ComlinkWorker") && !code.includes("ComlinkSharedWorker") ) return; + // Regex to match ComlinkWorker/ComlinkSharedWorker constructor patterns + // Captures: new keyword, constructor type, URL parameters, options, closing parenthesis const workerSearcher = /(\bnew\s+)(ComlinkWorker|ComlinkSharedWorker)(\s*\(\s*new\s+URL\s*\(\s*)('[^']+'|"[^"]+"|`[^`]+`)(\s*,\s*import\.meta\.url\s*\)\s*)(,?)([^\)]*)(\))/g; @@ -69,70 +126,85 @@ export function comlink(): Plugin[] { const matches = code.matchAll(workerSearcher); + // Process each matched ComlinkWorker/ComlinkSharedWorker constructor for (const match of matches) { const index = match.index!; const matchCode = match[0]; - const c1_new = match[1]; - const c2_type = match[2]; - const c3_new_url = match[3]; - let c4_path = match[4]; - const c5_import_meta = match[5]; - const c6_koma = match[6]; - const c7_options = match[7]; - const c8_end = match[8]; - + + // Extract regex capture groups + const c1_new = match[1]; // "new " keyword + const c2_type = match[2]; // "ComlinkWorker" or "ComlinkSharedWorker" + const c3_new_url = match[3]; // "new URL(" part + let c4_path = match[4]; // The quoted path string + const c5_import_meta = match[5]; // ", import.meta.url)" part + const c6_koma = match[6]; // Optional comma before options + const c7_options = match[7]; // Worker options object + const c8_end = match[8]; // Closing parenthesis + + // Parse worker options using JSON5 (supports comments, trailing commas, etc.) const opt = c7_options ? JSON5.parse(c7_options) : {}; + // Extract and remove quotes from the path const urlQuote = c4_path[0]; - c4_path = c4_path.substring(1, c4_path.length - 1); + // Force module type in development for better debugging experience if (mode === "development") { opt.type = "module"; } const options = JSON.stringify(opt); + // Determine virtual module prefix and native worker class based on type const prefix = c2_type === "ComlinkWorker" ? urlPrefix_normal : urlPrefix_shared; const className = c2_type == "ComlinkWorker" ? "Worker" : "SharedWorker"; + // Resolve the worker file path using Vite's resolution system const res = await this.resolve(c4_path, id, {}); let path = c4_path; if (res) { path = res.id; + // Convert absolute path to relative if it's within project root if (path.startsWith(root)) { path = path.substring(root.length); } } + + // Build the new worker constructor with virtual module URL const worker_constructor = `${c1_new}${className}${c3_new_url}${urlQuote}${prefix}${path}${urlQuote}${c5_import_meta},${options}${c8_end}`; + // SharedWorkers need .port property to access MessagePort const extra_shared = c2_type == "ComlinkWorker" ? "" : ".port"; + // Generate the final code that wraps the worker with Comlink const insertCode = `___wrap((${worker_constructor})${extra_shared});\n`; + // Replace the original constructor call with our transformed version s.overwrite(index, index + matchCode.length, insertCode); } + // Add import for Comlink wrap function at the top of the file s.appendLeft( 0, `import {wrap as ___wrap} from 'vite-plugin-comlink/symbol';\n` ); - // Generate source map for our transformations + // Generate source map for our transformations with high resolution const magicStringMap = s.generateMap({ source: id, includeContent: true, - hires: true + hires: true // High-resolution source maps for better debugging }); - // Get the existing source map from previous transforms + // Get the existing source map from previous transforms in the pipeline const existingMap = this.getCombinedSourcemap(); let finalMap = magicStringMap; - // If there's an existing source map, we need to combine them + // Combine source maps if there are previous transformations + // This ensures debugging works correctly through the entire transformation chain if (existingMap && existingMap.mappings && existingMap.mappings !== '') { try { // Create consumers for both source maps @@ -145,11 +217,12 @@ export function comlink(): Plugin[] { finalMap = generator.toJSON() as any; - // Clean up consumers + // Clean up consumers to prevent memory leaks existingConsumer.destroy(); newConsumer.destroy(); } catch (error) { - // If source map combination fails, fall back to magic string map + // If source map combination fails, fall back to our generated map + // This ensures the build doesn't fail due to source map issues console.warn('Failed to combine source maps:', error); finalMap = magicStringMap; } @@ -164,4 +237,5 @@ export function comlink(): Plugin[] { ]; } +// Export as default for convenience export default comlink; diff --git a/src/symbol.ts b/src/symbol.ts index 4fd799d..05b728c 100644 --- a/src/symbol.ts +++ b/src/symbol.ts @@ -1,4 +1,6 @@ import { wrap as comlink_wrap } from "comlink"; + +// Re-export commonly used Comlink utilities for convenience export { proxy, proxyMarker, @@ -6,16 +8,44 @@ export { releaseProxy, createEndpoint } from 'comlink' + +/** + * Symbol used to access the underlying Worker/SharedWorker instance + * from a Comlink-wrapped worker proxy. + * + * Usage: + * ```ts + * const worker = new ComlinkWorker( + * new URL('./worker', import.meta.url) + * ); + * const nativeWorker = worker[endpointSymbol]; // Access underlying Worker + * ``` + */ export const endpointSymbol = Symbol("getEndpoint"); /** - * internal API + * Enhanced wrap function that extends Comlink's wrap with endpoint access. + * + * This function wraps a Worker/SharedWorker endpoint with Comlink's proxy, + * but also adds the ability to access the original endpoint via a symbol. + * This allows users to access native Worker methods and properties when needed. + * + * @param ep - The endpoint (Worker, SharedWorker.port, MessagePort, etc.) to wrap + * @returns Comlink proxy with additional endpoint access via endpointSymbol + * + * @internal This is used internally by the plugin transformation */ export const wrap: typeof comlink_wrap = (ep) => { + // Create the standard Comlink proxy const wrapped = comlink_wrap(ep); + + // Enhance the proxy to expose the underlying endpoint via symbol return new Proxy(wrapped, { get(target, prop, receiver) { + // If accessing the endpoint symbol, return the original endpoint if (prop === endpointSymbol) return ep; + + // Otherwise, delegate to the wrapped Comlink proxy return Reflect.get(target, prop, receiver); } }) as any; diff --git a/test-minimal-example/src/conditional-worker.ts b/test-minimal-example/src/conditional-worker.ts new file mode 100644 index 0000000..ed112be --- /dev/null +++ b/test-minimal-example/src/conditional-worker.ts @@ -0,0 +1,41 @@ +export function conditionalWorkerCreation(shouldCreateShared: boolean) { + // Check if we're in a worker context or main thread + const isMainThread = typeof window !== 'undefined'; + const hasSharedWorker = isMainThread && typeof SharedWorker !== 'undefined'; + + if (shouldCreateShared && hasSharedWorker) { + return { type: 'shared', supported: true, context: 'main', hasSharedWorker }; + } else { + return { type: 'regular', supported: true, context: isMainThread ? 'main' : 'worker', hasSharedWorker }; + } +} + +export async function testWorkerInFunction() { + const worker = new ComlinkWorker( + new URL('./worker.ts', import.meta.url) + ); + + const result = await worker.add(10, 20); + return result; +} + +// Test multiple workers creation and management +let workers: Array = []; + +export async function createMultipleWorkers(count: number) { + workers = []; + for (let i = 0; i < count; i++) { + const worker = new ComlinkWorker( + new URL('./worker.ts', import.meta.url) + ); + workers.push(worker); + } + return workers.length; +} + +export async function testAllWorkers() { + const promises = workers.map((worker, index) => + worker.add(index, index + 1) + ); + return Promise.all(promises); +} \ No newline at end of file diff --git a/test-minimal-example/src/main.ts b/test-minimal-example/src/main.ts index 9d9cb6b..ee835c2 100644 --- a/test-minimal-example/src/main.ts +++ b/test-minimal-example/src/main.ts @@ -88,6 +88,108 @@ async function runTests() { }) } + // Test 4: Nested Worker Creation (simulation test) + try { + const nestedWorker = new ComlinkWorker( + new URL('./nested-worker.ts', import.meta.url) + ) + + // Test that the nested worker creates and uses another worker internally + const complexResult = await nestedWorker.processComplexData({ + numbers: [1, 2, 3], + multiplier: 3 + }) + + const asyncResult = await nestedWorker.asyncChainOperation() + + testResults.push({ + name: 'Nested Worker Simulation', + passed: complexResult === 18 && asyncResult === 20, + message: `Nested simulation: complex=${complexResult}, async=${asyncResult}` + }) + } catch (error) { + testResults.push({ + name: 'Nested Worker Simulation', + passed: false, + message: `Error: ${error}` + }) + } + + // Test 5: Complex Data Processing + try { + const nestedWorker = new ComlinkWorker( + new URL('./nested-worker.ts', import.meta.url) + ) + + const complexResult = await nestedWorker.processComplexData({ + numbers: [1, 2, 3, 4, 5], + multiplier: 2 + }) + + const asyncChainResult = await nestedWorker.asyncChainOperation() + + testResults.push({ + name: 'Complex Data Processing', + passed: complexResult === 30 && asyncChainResult === 20, + message: `Complex: sum=${complexResult}, chain=${asyncChainResult}` + }) + } catch (error) { + testResults.push({ + name: 'Complex Data Processing', + passed: false, + message: `Error: ${error}` + }) + } + + // Test 6: Worker in Function (moved up) + try { + const conditionalWorker = new ComlinkWorker( + new URL('./conditional-worker.ts', import.meta.url) + ) + + const functionResult = await conditionalWorker.testWorkerInFunction() + + testResults.push({ + name: 'Worker in Function', + passed: functionResult === 30, + message: `Function worker: add(10,20)=${functionResult}` + }) + } catch (error) { + testResults.push({ + name: 'Worker in Function', + passed: false, + message: `Error: ${error}` + }) + } + + // Test 7: Multiple Workers Management + try { + const conditionalWorker = new ComlinkWorker( + new URL('./conditional-worker.ts', import.meta.url) + ) + + const workerCount = await conditionalWorker.createMultipleWorkers(3) + const results = await conditionalWorker.testAllWorkers() + + const expectedResults = [1, 3, 5] // [0+1, 1+2, 2+3] + const resultsMatch = results.length === 3 && + results[0] === expectedResults[0] && + results[1] === expectedResults[1] && + results[2] === expectedResults[2] + + testResults.push({ + name: 'Multiple Workers Management', + passed: workerCount === 3 && resultsMatch, + message: `Manager: created=${workerCount}, results=[${results.join(', ')}]` + }) + } catch (error) { + testResults.push({ + name: 'Multiple Workers Management', + passed: false, + message: `Error: ${error}` + }) + } + // Display results const allPassed = testResults.every(r => r.passed) diff --git a/test-minimal-example/src/nested-worker.ts b/test-minimal-example/src/nested-worker.ts new file mode 100644 index 0000000..22d76b4 --- /dev/null +++ b/test-minimal-example/src/nested-worker.ts @@ -0,0 +1,27 @@ +// Test nested worker creation (simulated) +export async function createNestedWorker() { + // Create a worker to simulate nested worker operations + const nestedWorker = new ComlinkWorker( + new URL('./worker.ts', import.meta.url) + ); + + // Test the worker and return results instead of the worker itself + const addResult = await nestedWorker.add(5, 7); + const greetResult = await nestedWorker.greet('Nested'); + + return { + addViaNestedWorker: async () => addResult, + greetViaNestedWorker: async () => greetResult, + }; +} + +export function processComplexData(data: { numbers: number[]; multiplier: number }) { + return data.numbers.map(n => n * data.multiplier).reduce((sum, n) => sum + n, 0); +} + +export async function asyncChainOperation() { + const step1 = await new Promise(resolve => setTimeout(() => resolve(5), 50)); + const step2 = await new Promise(resolve => setTimeout(() => resolve(step1 * 2), 50)); + const step3 = await new Promise(resolve => setTimeout(() => resolve(step2 + 10), 50)); + return step3; +} \ No newline at end of file diff --git a/test-minimal-example/test-runner.js b/test-minimal-example/test-runner.js index 69f0708..c2d56ce 100644 --- a/test-minimal-example/test-runner.js +++ b/test-minimal-example/test-runner.js @@ -1,33 +1,22 @@ -import { chromium } from '@playwright/test' +import { chromium, firefox, webkit } from '@playwright/test' import { preview } from 'vite' import { fileURLToPath } from 'url' import { dirname, join } from 'path' const __dirname = dirname(fileURLToPath(import.meta.url)) -async function runTests() { - let server +async function runTestsInBrowser(browserType, browserName) { + console.log(`\n🌐 Testing in ${browserName}...`) + let browser let exitCode = 0 try { - // Build is already done by npm script - - // Start preview server - console.log('Starting preview server...') - server = await preview({ - root: __dirname, - preview: { - port: 4173 - } - }) - // Launch browser - console.log('Launching browser...') - browser = await chromium.launch() + browser = await browserType.launch({ headless: true }) const page = await browser.newPage() - // Collect console logs + // Collect console logs and errors const consoleLogs = [] page.on('console', msg => { if (msg.type() === 'log' && msg.text().includes('TEST RESULTS:')) { @@ -35,11 +24,15 @@ async function runTests() { } }) + page.on('pageerror', error => { + console.error(`${browserName} page error:`, error) + }) + // Navigate to the app await page.goto('http://localhost:4173') - // Wait for tests to complete - await page.waitForSelector('[data-tests-complete="true"]', { timeout: 30000 }) + // Wait for tests to complete with longer timeout for complex tests + await page.waitForSelector('[data-tests-complete="true"]', { timeout: 60000 }) // Check if all tests passed const testsPassed = await page.getAttribute('body', 'data-tests-passed') @@ -50,22 +43,77 @@ async function runTests() { return resultsEl ? resultsEl.innerText : 'No results found' }) - console.log('\n' + results) + console.log(`\nšŸ“Š ${browserName} Results:`) + console.log(results) if (testsPassed !== 'true') { - console.error('\nāŒ Some tests failed!') + console.error(`\nāŒ ${browserName}: Some tests failed!`) exitCode = 1 } else { - console.log('\nāœ… All tests passed!') + console.log(`\nāœ… ${browserName}: All tests passed!`) } } catch (error) { - console.error('Test runner error:', error) + console.error(`${browserName} test error:`, error) exitCode = 1 } finally { if (browser) await browser.close() + } + + return exitCode +} + +async function runTests() { + let server + let overallExitCode = 0 + + try { + // Build is already done by npm script + + // Start preview server + console.log('Starting preview server...') + server = await preview({ + root: __dirname, + preview: { + port: 4173 + } + }) + + // Wait a moment for server to be ready + await new Promise(resolve => setTimeout(resolve, 1000)) + + // Test in multiple browsers + const browsers = [ + { type: chromium, name: 'Chromium' }, + { type: firefox, name: 'Firefox' }, + // Safari/WebKit only works on macOS + ...(process.platform === 'darwin' ? [{ type: webkit, name: 'Safari' }] : []) + ] + + console.log(`\nšŸš€ Running tests in ${browsers.length} browser(s)...`) + + for (const { type, name } of browsers) { + try { + const exitCode = await runTestsInBrowser(type, name) + if (exitCode !== 0) overallExitCode = 1 + } catch (error) { + console.error(`Failed to test in ${name}:`, error) + overallExitCode = 1 + } + } + + if (overallExitCode === 0) { + console.log('\nšŸŽ‰ All browser tests passed!') + } else { + console.error('\nšŸ’„ Some browser tests failed!') + } + + } catch (error) { + console.error('Test runner error:', error) + overallExitCode = 1 + } finally { if (server) await server.close() - process.exit(exitCode) + process.exit(overallExitCode) } }