Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
```

---

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/create-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export async function createRuntime(
vfs: VirtualFS,
options: CreateRuntimeOptions = {}
): Promise<IRuntime> {
const { sandbox, dangerouslyAllowSameOrigin, useWorker = false, ...runtimeOptions } = options;
const { sandbox, dangerouslyAllowSameOrigin, useWorker = false, workerUrl, ...runtimeOptions } = options;

// SECURE: Cross-origin sandbox mode
if (sandbox) {
Expand Down Expand Up @@ -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;
Expand Down
63 changes: 62 additions & 1 deletion src/next-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
30 changes: 30 additions & 0 deletions src/runtime-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
34 changes: 26 additions & 8 deletions src/worker-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -29,21 +40,28 @@ export class WorkerRuntime implements IRuntime {
private worker: Worker;
private workerApi: Remote<WorkerApi>;
private vfs: VirtualFS;
private options: IRuntimeOptions;
private options: WorkerRuntimeOptions;
private initialized: Promise<void>;
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<WorkerApi>(this.worker);
Expand Down
30 changes: 29 additions & 1 deletion tests/next-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
5 changes: 5 additions & 0 deletions vite.lib.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]',
},
},
},
Expand Down