diff --git a/CHANGELOG.md b/CHANGELOG.md index 3333b41..64d86e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.15] - 2026-02-20 + +### Added +- **`workerUrl` option for `createRuntime()` and `WorkerRuntime`**: Pass a custom URL for the runtime Web Worker script. This is required when using almostnode with Turbopack or Webpack, which statically resolve `new URL(..., import.meta.url)` at build time and fail on server-relative asset paths from the almostnode dist. When `workerUrl` is omitted, the existing Vite-compatible behavior is preserved. +- **`getWorkerContent()` and `getWorkerPath()` in `almostnode/next`**: Helpers to read the built runtime worker script from the almostnode package, analogous to the existing `getServiceWorkerContent()` / `getServiceWorkerPath()`. Use these to serve the worker from a Next.js API route and pass the route URL as `workerUrl`. +- **Stable worker filename**: The runtime worker is now built as `dist/assets/runtime-worker.js` (no content hash) so it can be located and served reliably by consuming projects. + +### Changed +- Worker build output now uses stable filenames (`entryFileNames: '[name].js'`) instead of hashed names, making the file locatable without a glob. + ## [0.2.14] - 2026-02-14 ### Added diff --git a/README.md b/README.md index 51746e9..199aac1 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,44 @@ await bridge.initServiceWorker({ swUrl: '/api/__sw__' }); |--------|-------------| | `getServiceWorkerContent()` | Returns the service worker file content as a string | | `getServiceWorkerPath()` | Returns the absolute path to the service worker file | +| `getWorkerContent()` | Returns the runtime Web Worker script as a string (for Turbopack/Webpack) | +| `getWorkerPath()` | Returns the absolute path to the runtime Web Worker script | + +#### Using `WorkerRuntime` with Turbopack or Webpack + +If you use `useWorker: true` with `createRuntime()`, almostnode defaults to resolving the +worker script via `new URL(..., import.meta.url)`. **Turbopack and Webpack statically +analyze this pattern at build time** and fail when the path is a server-relative +`/assets/...` URL from the almostnode dist. + +To fix this, serve the worker file from a Next.js API route and pass its URL as `workerUrl`: + +```typescript +// app/api/almostnode-worker/route.ts +import { getWorkerContent } from 'almostnode/next'; + +export async function GET() { + return new Response(getWorkerContent(), { + headers: { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-cache', + }, + }); +} +``` + +Then pass `workerUrl` when creating the runtime: + +```typescript +// In your client component +import { createRuntime } from 'almostnode'; + +const runtime = await createRuntime(vfs, { + dangerouslyAllowSameOrigin: true, + useWorker: true, + workerUrl: '/api/almostnode-worker', +}); +``` --- diff --git a/package-lock.json b/package-lock.json index 9612856..d975aa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "almostnode", - "version": "0.2.13", + "version": "0.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "almostnode", - "version": "0.2.13", + "version": "0.2.14", "license": "MIT", "dependencies": { "@ai-sdk/openai": "^3.0.28", diff --git a/package.json b/package.json index 8fbd9d5..6583979 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "almostnode", - "version": "0.2.14", + "version": "0.2.15", "description": "Node.js in your browser. Just like that.", "type": "module", "license": "MIT", diff --git a/src/create-runtime.ts b/src/create-runtime.ts index d37c125..6b19522 100644 --- a/src/create-runtime.ts +++ b/src/create-runtime.ts @@ -84,7 +84,7 @@ export async function createRuntime( vfs: VirtualFS, options: CreateRuntimeOptions = {} ): Promise { - const { sandbox, dangerouslyAllowSameOrigin, useWorker = false, ...runtimeOptions } = options; + const { sandbox, dangerouslyAllowSameOrigin, useWorker = false, workerUrl, ...runtimeOptions } = options; // SECURE: Cross-origin sandbox mode if (sandbox) { @@ -124,7 +124,7 @@ export async function createRuntime( if (shouldUseWorker) { console.log('[createRuntime] Creating WorkerRuntime (same-origin, thread-isolated)'); - const workerRuntime = new WorkerRuntime(vfs, runtimeOptions); + const workerRuntime = new WorkerRuntime(vfs, { ...runtimeOptions, workerUrl }); // Wait for worker to be ready by executing a simple command await workerRuntime.execute('/* worker ready check */', '/__worker_init__.js'); return workerRuntime; diff --git a/src/next-plugin.ts b/src/next-plugin.ts index 41806a0..e1eb55e 100644 --- a/src/next-plugin.ts +++ b/src/next-plugin.ts @@ -93,4 +93,65 @@ export function getServiceWorkerPath(): string { return swFilePath; } -export default { getServiceWorkerContent, getServiceWorkerPath }; +/** + * Get the path to the almostnode runtime worker script. + * + * The runtime worker is a Web Worker used by `WorkerRuntime` to execute Node.js + * code off the main thread. By default, `WorkerRuntime` resolves the worker via + * `new URL(..., import.meta.url)`, which Vite handles correctly but Turbopack + * and Webpack cannot — they try to statically resolve the asset at build time + * and fail when the path is a server-relative `/assets/...` URL. + * + * To fix this, serve the worker file yourself and pass its URL as `workerUrl` + * to `createRuntime()` or `new WorkerRuntime()`. + * + * @example Next.js (App Router) — serve the worker from an API route + * ```typescript + * // app/api/almostnode-worker/route.ts + * import { getWorkerContent } from 'almostnode/next'; + * + * export async function GET() { + * return new Response(getWorkerContent(), { + * headers: { + * 'Content-Type': 'application/javascript', + * 'Cache-Control': 'no-cache', + * }, + * }); + * } + * + * // In your client component: + * const runtime = await createRuntime(vfs, { + * dangerouslyAllowSameOrigin: true, + * useWorker: true, + * workerUrl: '/api/almostnode-worker', + * }); + * ``` + */ +export function getWorkerPath(): string { + // The worker file is built to dist/assets/runtime-worker.js (stable name, no hash) + let workerFilePath = path.join(__dirname, 'assets', 'runtime-worker.js'); + + if (!fs.existsSync(workerFilePath)) { + workerFilePath = path.join(__dirname, '../dist/assets/runtime-worker.js'); + } + + if (!fs.existsSync(workerFilePath)) { + throw new Error( + 'almostnode runtime worker file not found. Make sure almostnode is built (`npm run build:lib`).' + ); + } + + return workerFilePath; +} + +/** + * Get the contents of the almostnode runtime worker script as a string. + * Use this in a Next.js API route to serve the worker to the browser. + * + * @see {@link getWorkerPath} for usage examples. + */ +export function getWorkerContent(): string { + return fs.readFileSync(getWorkerPath(), 'utf-8'); +} + +export default { getServiceWorkerContent, getServiceWorkerPath, getWorkerContent, getWorkerPath }; diff --git a/src/runtime-interface.ts b/src/runtime-interface.ts index f0dff1a..aaf0a2e 100644 --- a/src/runtime-interface.ts +++ b/src/runtime-interface.ts @@ -87,6 +87,36 @@ export interface CreateRuntimeOptions extends IRuntimeOptions { * They still have access to IndexedDB and can make network requests. */ useWorker?: boolean | 'auto'; + + /** + * URL of the pre-built almostnode runtime worker script. + * + * By default, WorkerRuntime uses `new URL('./worker/runtime-worker.ts', import.meta.url)` + * which Vite resolves at build time. This works fine with Vite, but breaks with Turbopack + * and Webpack because they try to statically resolve the asset path at build time and fail + * when the path is a server-relative `/assets/...` URL from the almostnode dist. + * + * To fix this, serve the worker file yourself and pass its URL here: + * + * @example Next.js (App Router) + * ```typescript + * // app/api/almostnode-worker/route.ts + * import { getWorkerContent } from 'almostnode/next'; + * export async function GET() { + * return new Response(getWorkerContent(), { + * headers: { 'Content-Type': 'application/javascript' }, + * }); + * } + * + * // In your component: + * const runtime = await createRuntime(vfs, { + * dangerouslyAllowSameOrigin: true, + * useWorker: true, + * workerUrl: '/api/almostnode-worker', + * }); + * ``` + */ + workerUrl?: string | URL; } /** diff --git a/src/worker-runtime.ts b/src/worker-runtime.ts index fa3a7f7..4e6505a 100644 --- a/src/worker-runtime.ts +++ b/src/worker-runtime.ts @@ -9,6 +9,17 @@ import { wrap, proxy, Remote } from 'comlink'; import type { VirtualFS } from './virtual-fs'; import type { IRuntime, IExecuteResult, IRuntimeOptions, VFSSnapshot } from './runtime-interface'; +export interface WorkerRuntimeOptions extends IRuntimeOptions { + /** + * URL of the pre-built runtime worker script. + * When omitted, uses Vite's `new URL(...)` worker syntax (works with Vite only). + * Set this when using Turbopack, Webpack, or any bundler that statically + * resolves `new URL(..., import.meta.url)` at build time. + * See `getWorkerContent()` in `almostnode/next` for how to serve this file. + */ + workerUrl?: string | URL; +} + /** * Type for the worker API */ @@ -29,21 +40,28 @@ export class WorkerRuntime implements IRuntime { private worker: Worker; private workerApi: Remote; private vfs: VirtualFS; - private options: IRuntimeOptions; + private options: WorkerRuntimeOptions; private initialized: Promise; private changeListener: ((path: string, content: string) => void) | null = null; private deleteListener: ((path: string) => void) | null = null; - constructor(vfs: VirtualFS, options: IRuntimeOptions = {}) { + constructor(vfs: VirtualFS, options: WorkerRuntimeOptions = {}) { this.vfs = vfs; this.options = options; - // Create the worker - // Using Vite's worker import syntax - this.worker = new Worker( - new URL('./worker/runtime-worker.ts', import.meta.url), - { type: 'module' } - ); + // Create the worker. + // If a workerUrl is provided, use it directly. This is required for bundlers + // that statically resolve `new URL(..., import.meta.url)` at build time + // (Turbopack, Webpack) and fail when the path is a server-relative asset URL. + // When no workerUrl is given, fall back to Vite's worker import syntax. + if (options.workerUrl) { + this.worker = new Worker(options.workerUrl, { type: 'module' }); + } else { + this.worker = new Worker( + new URL('./worker/runtime-worker.ts', import.meta.url), + { type: 'module' } + ); + } // Wrap with Comlink this.workerApi = wrap(this.worker); diff --git a/tests/next-plugin.test.ts b/tests/next-plugin.test.ts index 2b9c349..ad3fa1d 100644 --- a/tests/next-plugin.test.ts +++ b/tests/next-plugin.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getServiceWorkerContent, getServiceWorkerPath } from '../src/next-plugin'; +import { getServiceWorkerContent, getServiceWorkerPath, getWorkerContent, getWorkerPath } from '../src/next-plugin'; import * as fs from 'fs'; describe('next-plugin', () => { @@ -36,4 +36,32 @@ describe('next-plugin', () => { expect(content).toBe(fileContent); }); }); + + describe('getWorkerPath', () => { + it('should return a valid file path', () => { + const workerPath = getWorkerPath(); + expect(typeof workerPath).toBe('string'); + expect(workerPath).toContain('runtime-worker.js'); + }); + + it('should return a path that exists', () => { + const workerPath = getWorkerPath(); + expect(fs.existsSync(workerPath)).toBe(true); + }); + }); + + describe('getWorkerContent', () => { + it('should return worker content as a string', () => { + const content = getWorkerContent(); + expect(typeof content).toBe('string'); + expect(content.length).toBeGreaterThan(0); + }); + + it('should match the file content from getWorkerPath', () => { + const content = getWorkerContent(); + const workerPath = getWorkerPath(); + const fileContent = fs.readFileSync(workerPath, 'utf-8'); + expect(content).toBe(fileContent); + }); + }); }); diff --git a/vite.lib.config.js b/vite.lib.config.js index 6f13065..d00d441 100644 --- a/vite.lib.config.js +++ b/vite.lib.config.js @@ -46,6 +46,11 @@ export default defineConfig({ rollupOptions: { output: { inlineDynamicImports: true, + // Stable filename (no hash) so next-plugin.ts can locate the worker + // without a glob and consumers can serve it from a predictable path. + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', }, }, },