diff --git a/.gitignore b/.gitignore index 73632f4d6b382d777898b8cd3af2961a11c7fa06..218838580b866627e5ae406f83e54414694063df 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,8 @@ node_modules/ # Build output dist/ +# Generated files +src/worker/embedded-worker.ts + # Credential database (contains encrypted API keys) credentials.db diff --git a/example-project/public/test-inline.html b/example-project/public/test-inline.html new file mode 100644 index 0000000000000000000000000000000000000000..7a29291ecbfd8ee0b41205df4d51f55e1ac1a572 --- /dev/null +++ b/example-project/public/test-inline.html @@ -0,0 +1,60 @@ + + + + Inline Worker Test + + + +

AI-Delegate Inline Worker Test

+

This test verifies the inline worker works without a server.

+
+ + + + + diff --git a/package.json b/package.json index 033d59242dd740bfbbe7d43d69ad32cc9d695d40..38a7c1fb5755ca0db8e857ec889e285fb19e2651 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@peerverity/ai-delegate", - "version": "0.3.3", + "version": "0.3.4", "description": "AI request manager with credential management and weight-based model selection", "main": "dist/ai-delegate.js", "types": "dist/index.d.ts", @@ -25,12 +25,18 @@ "dist" ], "scripts": { - "build": "webpack --mode production && npm run build:types", - "build:dev": "webpack --mode development && npm run build:types", + "prebuild": "node scripts/ensure-embedded-stub.js", + "build": "npm run build:worker && npm run embed-worker && npm run build:main && npm run build:types", + "build:dev": "node scripts/ensure-embedded-stub.js && npm run build:worker:dev && npm run embed-worker && npm run build:main:dev && npm run build:types", + "build:worker": "webpack --mode production --config webpack.worker.config.js", + "build:worker:dev": "webpack --mode development --config webpack.worker.config.js", + "build:main": "webpack --mode production --config webpack.main.config.js", + "build:main:dev": "webpack --mode development --config webpack.main.config.js", "build:types": "tsc --emitDeclarationOnly --declaration --declarationMap", + "embed-worker": "node scripts/embed-worker.js", "prepare": "npm run build", - "watch": "webpack --mode development --watch", - "clean": "rimraf dist", + "watch": "npm run build:worker:dev && npm run embed-worker && webpack --mode development --config webpack.main.config.js --watch", + "clean": "rimraf dist src/worker/embedded-worker.ts", "serve": "node scripts/serve.js", "example:install": "npm --prefix example-project install", "example:start": "npm --prefix example-project start", diff --git a/scripts/embed-worker.js b/scripts/embed-worker.js new file mode 100755 index 0000000000000000000000000000000000000000..20bdaecb5339ebcc63584f2085ffe18b249ba72e --- /dev/null +++ b/scripts/embed-worker.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Embeds the compiled worker bundle into a TypeScript file + * that can be imported by the main bundle. + * + * This enables inline worker creation via Blob URL for offline/local mode. + * + * We use base64 encoding to safely embed the worker code without escaping issues. + */ + +const fs = require('fs'); +const path = require('path'); + +const WORKER_PATH = path.resolve(__dirname, '../dist/ai-delegate-worker.js'); +const OUTPUT_PATH = path.resolve(__dirname, '../src/worker/embedded-worker.ts'); + +function embedWorker() { + console.log('[embed-worker] Reading worker bundle...'); + + if (!fs.existsSync(WORKER_PATH)) { + console.error(`[embed-worker] ERROR: Worker bundle not found at ${WORKER_PATH}`); + console.error('[embed-worker] Run "npm run build:worker" first.'); + process.exit(1); + } + + const workerCode = fs.readFileSync(WORKER_PATH, 'utf8'); + const version = extractVersion(workerCode); + + // Base64 encode the worker code to avoid any escaping issues + const base64Code = Buffer.from(workerCode, 'utf8').toString('base64'); + + const output = `/** + * AUTO-GENERATED FILE - DO NOT EDIT + * + * This file contains the embedded worker bundle for inline worker creation. + * Generated by scripts/embed-worker.js + * + * To regenerate: npm run build:worker && npm run embed-worker + */ + +/** + * The worker source code as a base64-encoded string. + */ +const EMBEDDED_WORKER_BASE64 = '${base64Code}'; + +/** + * Decode the base64-encoded worker source. + */ +function decodeWorkerSource(): string { + // Use atob for browser environment + if (typeof atob === 'function') { + return atob(EMBEDDED_WORKER_BASE64); + } + // Fallback for Node.js environment (for testing) + return Buffer.from(EMBEDDED_WORKER_BASE64, 'base64').toString('utf8'); +} + +/** + * The worker source code as a string. + * Lazily decoded on first access. + */ +let _cachedSource: string | null = null; +export function getWorkerSource(): string { + if (_cachedSource === null) { + _cachedSource = decodeWorkerSource(); + } + return _cachedSource; +} + +/** + * Creates a Blob URL for the embedded worker. + * The URL should be revoked with URL.revokeObjectURL() when no longer needed. + */ +export function createWorkerBlobUrl(): string { + const source = getWorkerSource(); + const blob = new Blob([source], { type: 'application/javascript' }); + return URL.createObjectURL(blob); +} + +/** + * Version marker to help with debugging cache issues. + * This is extracted from the worker bundle if present. + */ +export const EMBEDDED_WORKER_VERSION: string = '${version}'; +`; + + console.log('[embed-worker] Writing embedded worker source...'); + fs.writeFileSync(OUTPUT_PATH, output, 'utf8'); + + const stats = fs.statSync(WORKER_PATH); + const base64Size = base64Code.length; + console.log(`[embed-worker] Original size: ${(stats.size / 1024).toFixed(1)}KB`); + console.log(`[embed-worker] Base64 size: ${(base64Size / 1024).toFixed(1)}KB`); + console.log(`[embed-worker] Worker version: ${version}`); + console.log(`[embed-worker] Output: ${OUTPUT_PATH}`); +} + +function extractVersion(code) { + // Try to extract version from worker code (looks for Version: X.Y.Z pattern) + const match = code.match(/Version:\s*([\d.]+)/); + return match ? match[1] : 'unknown'; +} + +embedWorker(); diff --git a/scripts/ensure-embedded-stub.js b/scripts/ensure-embedded-stub.js new file mode 100644 index 0000000000000000000000000000000000000000..223e0d392bfd30f82592d7e07c96252c350d30c5 --- /dev/null +++ b/scripts/ensure-embedded-stub.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Creates a stub embedded-worker.ts if it doesn't exist. + * This allows the TypeScript compilation to succeed before the + * real embedded worker is generated. + */ + +const fs = require('fs'); +const path = require('path'); + +const OUTPUT_PATH = path.resolve(__dirname, '../src/worker/embedded-worker.ts'); + +if (fs.existsSync(OUTPUT_PATH)) { + console.log('[ensure-embedded-stub] embedded-worker.ts already exists, skipping.'); + process.exit(0); +} + +const stub = `/** + * PLACEHOLDER - AUTO-GENERATED + * This stub is created before the build and will be replaced + * by the real embedded worker after build:worker completes. + */ + +export const EMBEDDED_WORKER_VERSION: string = '0.0.0-stub'; + +export function createWorkerBlobUrl(): string { + throw new Error('Worker not yet embedded - this is a build-time stub'); +} + +export function getWorkerSource(): string { + throw new Error('Worker not yet embedded - this is a build-time stub'); +} +`; + +console.log('[ensure-embedded-stub] Creating placeholder embedded-worker.ts...'); +fs.writeFileSync(OUTPUT_PATH, stub, 'utf8'); +console.log('[ensure-embedded-stub] Done.'); diff --git a/src/core/AiDelegate.ts b/src/core/AiDelegate.ts index 54891b6a4327011886de81b66fce49c1d0dc2bd1..77a5c647aee1d2cb3a6098b93f622465629e2397 100644 --- a/src/core/AiDelegate.ts +++ b/src/core/AiDelegate.ts @@ -123,8 +123,10 @@ export class AiDelegate { return; } - const workerUrl = this.getWorkerUrl(); - await this.worker.initialize(workerUrl, this.credentialServerUrl, this.allowLocalMode); + await this.worker.initialize( + this.credentialServerUrl, + this.allowLocalMode + ); // Set up prompt handler for worker this.setupPromptHandler(); @@ -151,18 +153,6 @@ export class AiDelegate { } } - /** - * Get the URL for the worker script based on the configured endpoint - */ - private getWorkerUrl(): string { - if (!this.endpoint) { - throw new Error('Cannot get worker URL: endpoint not configured'); - } - - // Worker is served at the endpoint path - return `${this.endpoint}/ai-delegate-worker.js`; - } - /** * Set up handler for prompt requests from worker */ diff --git a/src/worker/WorkerWrapper.ts b/src/worker/WorkerWrapper.ts index 5eb7262e4cd400761863331f9914205933f0dc01..9451f48610ab71ef3d96f3ff761220d4f3cda240 100644 --- a/src/worker/WorkerWrapper.ts +++ b/src/worker/WorkerWrapper.ts @@ -17,6 +17,7 @@ import { InitPayload } from './types'; import { MessageProtocol } from './MessageProtocol'; +import { createWorkerBlobUrl, EMBEDDED_WORKER_VERSION } from './embedded-worker'; /** * Main thread wrapper for worker communication @@ -24,6 +25,7 @@ import { MessageProtocol } from './MessageProtocol'; */ export class WorkerWrapper { private worker: Worker | null = null; + private blobUrl: string | null = null; // Track blob URL for cleanup private pendingRequests: Map void; reject: (error: Error) => void; @@ -32,22 +34,25 @@ export class WorkerWrapper { /** * Initialize the worker - * @param workerUrl - URL to the worker script * @param credentialServerUrl - Optional URL of the credential server for proxied mode * @param allowLocalMode - Whether to allow users to switch to local mode */ - async initialize(workerUrl: string, credentialServerUrl?: string, allowLocalMode?: boolean): Promise { + async initialize( + credentialServerUrl?: string, + allowLocalMode?: boolean + ): Promise { if (this.worker) { return; // Already initialized } - this.worker = new Worker(workerUrl); + // Always use inline worker via Blob URL + this.createInlineWorker(); - this.worker.addEventListener('message', (event: MessageEvent) => { + this.worker!.addEventListener('message', (event: MessageEvent) => { this.handleWorkerMessage(event.data); }); - this.worker.addEventListener('error', (event: ErrorEvent) => { + this.worker!.addEventListener('error', (event: ErrorEvent) => { console.error('[WorkerWrapper] Worker error:', event.error); // Reject all pending requests this.pendingRequests.forEach(({ reject }) => { @@ -61,6 +66,15 @@ export class WorkerWrapper { await this.sendCommand(WorkerCommand.INIT, payload); } + /** + * Create worker from embedded source using Blob URL + */ + private createInlineWorker(): void { + console.log(`[WorkerWrapper] Creating inline worker (embedded version: ${EMBEDDED_WORKER_VERSION})`); + this.blobUrl = createWorkerBlobUrl(); + this.worker = new Worker(this.blobUrl); + } + /** * Handle messages from worker */ @@ -251,6 +265,12 @@ export class WorkerWrapper { this.worker = null; } + // Revoke blob URL if we created one + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = null; + } + // Reject all pending requests this.pendingRequests.forEach(({ reject }) => { reject(new Error('Worker terminated')); diff --git a/src/worker/worker.ts b/src/worker/worker.ts index 7738e5337a8f3335a5361cbab39eff09a1543230..720eb0236b5306655d35ece4e9cb91aedd6401e0 100644 --- a/src/worker/worker.ts +++ b/src/worker/worker.ts @@ -13,8 +13,11 @@ import { PromptConflict } from '../types/prompts'; * Credentials and sensitive data NEVER leave this context */ +// Version injected by webpack DefinePlugin from package.json +declare const __AI_DELEGATE_VERSION__: string; + // Version marker for debugging cache issues -console.log('[AI Delegate Worker] Version: 0.3.2'); +console.log(`[AI Delegate Worker] Version: ${__AI_DELEGATE_VERSION__}`); // Storage and config manager (initialized immediately) const storage = new IndexedDBAdapter(); diff --git a/webpack.main.config.js b/webpack.main.config.js new file mode 100644 index 0000000000000000000000000000000000000000..f48cd4196d8a57063c4490c3f153fb3a5683e513 --- /dev/null +++ b/webpack.main.config.js @@ -0,0 +1,111 @@ +/** + * Webpack config for building the main bundle, server, and credential-server. + * This is built after the worker bundle and embed-worker script have run. + */ +const path = require('path'); + +module.exports = [ + // Main bundle (for the browser/main thread) + { + entry: './src/index.ts', + output: { + filename: 'ai-delegate.js', + path: path.resolve(__dirname, 'dist'), + library: { + name: 'AiDelegate', + type: 'umd', + export: 'default' + }, + globalObject: 'this' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + optimization: { + minimize: true + }, + devtool: 'source-map' + }, + // Server bundle (for Node.js) + { + entry: './src/server/index.ts', + output: { + filename: 'server/index.js', + path: path.resolve(__dirname, 'dist'), + library: { + type: 'commonjs2' + } + }, + target: 'node', + externals: { + // Don't bundle Node.js built-in modules + fs: 'commonjs fs', + path: 'commonjs path', + http: 'commonjs http', + 'better-sqlite3': 'commonjs better-sqlite3' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + optimization: { + minimize: false // Don't minimize server code for easier debugging + }, + devtool: 'source-map' + }, + // Credential Server bundle (for Node.js) + { + entry: './src/credential-server/index.ts', + output: { + filename: 'credential-server/index.js', + path: path.resolve(__dirname, 'dist'), + library: { + type: 'commonjs2' + } + }, + target: 'node', + externals: { + // Don't bundle Node.js built-in modules + fs: 'commonjs fs', + path: 'commonjs path', + http: 'commonjs http', + https: 'commonjs https', + crypto: 'commonjs crypto', + url: 'commonjs url', + 'better-sqlite3': 'commonjs better-sqlite3' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + optimization: { + minimize: false // Don't minimize server code for easier debugging + }, + devtool: 'source-map' + } +]; diff --git a/webpack.worker.config.js b/webpack.worker.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ceabb10bf15e7128be82dfa5b5ce05b930eb09f9 --- /dev/null +++ b/webpack.worker.config.js @@ -0,0 +1,38 @@ +/** + * Webpack config for building the worker bundle. + * This is built first, before the main bundle. + */ +const path = require('path'); +const webpack = require('webpack'); +const packageJson = require('./package.json'); + +module.exports = { + entry: './src/worker-entry.ts', + output: { + filename: 'ai-delegate-worker.js', + path: path.resolve(__dirname, 'dist'), + globalObject: 'self' // Important: use 'self' for worker context + }, + target: 'webworker', // Target web worker environment + resolve: { + extensions: ['.ts', '.js'] + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + optimization: { + minimize: true + }, + plugins: [ + new webpack.DefinePlugin({ + __AI_DELEGATE_VERSION__: JSON.stringify(packageJson.version) + }) + ], + devtool: 'source-map' +};