From 574a8b13b5dcfa7ba9d75883c79066e68a9ed4bc Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 20 Feb 2026 11:08:12 -0400 Subject: [PATCH 1/3] make worker url fetch bundler-agnostic --- CHANGELOG.md | 10 ++++ README.md | 38 ++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/create-runtime.ts | 4 +- src/next-plugin.ts | 63 ++++++++++++++++++++++++- src/runtime-interface.ts | 30 ++++++++++++ src/worker-runtime.ts | 34 ++++++++++---- tests/next-plugin.test.ts | 30 +++++++++++- vite.lib.config.js | 96 +++++++++++++++++++++------------------ 10 files changed, 252 insertions(+), 59 deletions(-) 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..ff242a5 100644 --- a/vite.lib.config.js +++ b/vite.lib.config.js @@ -1,24 +1,30 @@ -import { defineConfig } from 'vite'; -import { resolve } from 'path'; -import wasm from 'vite-plugin-wasm'; - +import { defineConfig } from "vite"; +import { resolve } from "path"; +import wasm from "vite-plugin-wasm"; export default defineConfig({ plugins: [ wasm(), { - name: 'browser-shims', - enforce: 'pre', + name: "browser-shims", + enforce: "pre", resolveId(source) { - if (source === 'node:zlib' || source === 'zlib') { - return resolve(__dirname, 'src/shims/zlib.ts'); + if (source === "node:zlib" || source === "zlib") { + return resolve(__dirname, "src/shims/zlib.ts"); } - if (source === 'brotli-wasm/pkg.web/brotli_wasm.js') { - return resolve(__dirname, 'node_modules/brotli-wasm/pkg.web/brotli_wasm.js'); + if (source === "brotli-wasm/pkg.web/brotli_wasm.js") { + return resolve( + __dirname, + "node_modules/brotli-wasm/pkg.web/brotli_wasm.js", + ); } - if (source === 'brotli-wasm/pkg.web/brotli_wasm_bg.wasm?url') { + if (source === "brotli-wasm/pkg.web/brotli_wasm_bg.wasm?url") { return { - id: resolve(__dirname, 'node_modules/brotli-wasm/pkg.web/brotli_wasm_bg.wasm') + '?url', + id: + resolve( + __dirname, + "node_modules/brotli-wasm/pkg.web/brotli_wasm_bg.wasm", + ) + "?url", external: false, }; } @@ -27,65 +33,67 @@ export default defineConfig({ }, ], define: { - 'process.env': {}, - global: 'globalThis', + "process.env": {}, + global: "globalThis", }, resolve: { alias: { - 'node:zlib': resolve(__dirname, 'src/shims/zlib.ts'), - 'zlib': resolve(__dirname, 'src/shims/zlib.ts'), - 'buffer': 'buffer', - 'process': 'process/browser', + "node:zlib": resolve(__dirname, "src/shims/zlib.ts"), + zlib: resolve(__dirname, "src/shims/zlib.ts"), + buffer: "buffer", + process: "process/browser", }, }, worker: { - format: 'es', - plugins: () => [ - wasm(), - ], + format: "es", + plugins: () => [wasm()], rollupOptions: { output: { inlineDynamicImports: true, + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + assetFileNames: "[name][extname]", }, }, }, build: { lib: { entry: { - index: resolve(__dirname, 'src/index.ts'), - 'vite-plugin': resolve(__dirname, 'src/vite-plugin.ts'), - 'next-plugin': resolve(__dirname, 'src/next-plugin.ts'), + index: resolve(__dirname, "src/index.ts"), + "vite-plugin": resolve(__dirname, "src/vite-plugin.ts"), + "next-plugin": resolve(__dirname, "src/next-plugin.ts"), }, - name: 'JustNode', - formats: ['es', 'cjs'], - fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'mjs' : 'cjs'}`, + name: "JustNode", + formats: ["es", "cjs"], + fileName: (format, entryName) => + `${entryName}.${format === "es" ? "mjs" : "cjs"}`, }, rollupOptions: { external: [ - 'brotli-wasm', - 'pako', - 'comlink', - 'just-bash', - 'resolve.exports', - 'brotli', + "brotli-wasm", + "pako", + "comlink", + "just-bash", + "resolve.exports", + "brotli", // Node.js built-ins for vite-plugin - 'fs', - 'path', - 'url', - 'vite', + "fs", + "path", + "url", + "vite", ], output: { globals: { - 'brotli-wasm': 'brotliWasm', - 'pako': 'pako', - 'comlink': 'Comlink', - 'just-bash': 'justBash', - 'resolve.exports': 'resolveExports', + "brotli-wasm": "brotliWasm", + pako: "pako", + comlink: "Comlink", + "just-bash": "justBash", + "resolve.exports": "resolveExports", }, }, }, sourcemap: true, minify: false, }, - assetsInclude: ['**/*.wasm'], + assetsInclude: ["**/*.wasm"], }); From e8287a9e827b287c05448ead933781b69322aca8 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 20 Feb 2026 11:10:12 -0400 Subject: [PATCH 2/3] undo format --- vite.lib.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vite.lib.config.js b/vite.lib.config.js index ff242a5..67cd7e6 100644 --- a/vite.lib.config.js +++ b/vite.lib.config.js @@ -50,6 +50,8 @@ 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]", From 3d6a12cf4b3dc545682bd157819de64749d5454c Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 20 Feb 2026 11:12:39 -0400 Subject: [PATCH 3/3] fm --- vite.lib.config.js | 99 ++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/vite.lib.config.js b/vite.lib.config.js index 67cd7e6..d00d441 100644 --- a/vite.lib.config.js +++ b/vite.lib.config.js @@ -1,30 +1,24 @@ -import { defineConfig } from "vite"; -import { resolve } from "path"; -import wasm from "vite-plugin-wasm"; +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import wasm from 'vite-plugin-wasm'; + export default defineConfig({ plugins: [ wasm(), { - name: "browser-shims", - enforce: "pre", + name: 'browser-shims', + enforce: 'pre', resolveId(source) { - if (source === "node:zlib" || source === "zlib") { - return resolve(__dirname, "src/shims/zlib.ts"); + if (source === 'node:zlib' || source === 'zlib') { + return resolve(__dirname, 'src/shims/zlib.ts'); } - if (source === "brotli-wasm/pkg.web/brotli_wasm.js") { - return resolve( - __dirname, - "node_modules/brotli-wasm/pkg.web/brotli_wasm.js", - ); + if (source === 'brotli-wasm/pkg.web/brotli_wasm.js') { + return resolve(__dirname, 'node_modules/brotli-wasm/pkg.web/brotli_wasm.js'); } - if (source === "brotli-wasm/pkg.web/brotli_wasm_bg.wasm?url") { + if (source === 'brotli-wasm/pkg.web/brotli_wasm_bg.wasm?url') { return { - id: - resolve( - __dirname, - "node_modules/brotli-wasm/pkg.web/brotli_wasm_bg.wasm", - ) + "?url", + id: resolve(__dirname, 'node_modules/brotli-wasm/pkg.web/brotli_wasm_bg.wasm') + '?url', external: false, }; } @@ -33,69 +27,70 @@ export default defineConfig({ }, ], define: { - "process.env": {}, - global: "globalThis", + 'process.env': {}, + global: 'globalThis', }, resolve: { alias: { - "node:zlib": resolve(__dirname, "src/shims/zlib.ts"), - zlib: resolve(__dirname, "src/shims/zlib.ts"), - buffer: "buffer", - process: "process/browser", + 'node:zlib': resolve(__dirname, 'src/shims/zlib.ts'), + 'zlib': resolve(__dirname, 'src/shims/zlib.ts'), + 'buffer': 'buffer', + 'process': 'process/browser', }, }, worker: { - format: "es", - plugins: () => [wasm()], + format: 'es', + plugins: () => [ + wasm(), + ], 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]", + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', }, }, }, build: { lib: { entry: { - index: resolve(__dirname, "src/index.ts"), - "vite-plugin": resolve(__dirname, "src/vite-plugin.ts"), - "next-plugin": resolve(__dirname, "src/next-plugin.ts"), + index: resolve(__dirname, 'src/index.ts'), + 'vite-plugin': resolve(__dirname, 'src/vite-plugin.ts'), + 'next-plugin': resolve(__dirname, 'src/next-plugin.ts'), }, - name: "JustNode", - formats: ["es", "cjs"], - fileName: (format, entryName) => - `${entryName}.${format === "es" ? "mjs" : "cjs"}`, + name: 'JustNode', + formats: ['es', 'cjs'], + fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'mjs' : 'cjs'}`, }, rollupOptions: { external: [ - "brotli-wasm", - "pako", - "comlink", - "just-bash", - "resolve.exports", - "brotli", + 'brotli-wasm', + 'pako', + 'comlink', + 'just-bash', + 'resolve.exports', + 'brotli', // Node.js built-ins for vite-plugin - "fs", - "path", - "url", - "vite", + 'fs', + 'path', + 'url', + 'vite', ], output: { globals: { - "brotli-wasm": "brotliWasm", - pako: "pako", - comlink: "Comlink", - "just-bash": "justBash", - "resolve.exports": "resolveExports", + 'brotli-wasm': 'brotliWasm', + 'pako': 'pako', + 'comlink': 'Comlink', + 'just-bash': 'justBash', + 'resolve.exports': 'resolveExports', }, }, }, sourcemap: true, minify: false, }, - assetsInclude: ["**/*.wasm"], + assetsInclude: ['**/*.wasm'], });