From 1902e2957d4c5a78bcb67e702ee2d037030aec68 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 1 Oct 2026 18:22:07 +0900 Subject: [PATCH 1/3] fix(vite-dev-server): write ArrayBuffer setup files as bytes in the Web Worker The Web Worker wrote every file given in V_WW_SETUP with { encoding: 'utf8' }, so an ArrayBuffer was written as the string "[object ArrayBuffer]". updateFile() now writes an ArrayBuffer as bytes, as the @vrowzer/fs subscriber does for binary content, and a string as UTF-8 text as before. SetupWorkerMessage.files and setupWorker() accept ArrayBuffer values. --- .../src/node/transformer.test.ts | 48 +++++++++++++++++-- .../vite-dev-server/src/node/transformer.ts | 16 +++++-- .../vite-dev-server/src/shared/messages.ts | 5 +- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/vite-dev-server/src/node/transformer.test.ts b/packages/vite-dev-server/src/node/transformer.test.ts index 7ef8900c..d847fe55 100644 --- a/packages/vite-dev-server/src/node/transformer.test.ts +++ b/packages/vite-dev-server/src/node/transformer.test.ts @@ -1,3 +1,4 @@ +import { fs } from '@vrowzer/fs' import { createVirtualFSWatcher } from '@vrowzer/fs/watcher' import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vite-plus/test' @@ -8,9 +9,13 @@ const hmrMocks = vi.hoisted(() => ({ })) // transformer.ts is also a runtime barrel. Stub its re-export graph so this -// test exercises setupWorker and setupHMR without initializing browser WASM. +// test exercises setupWorker, setupHMR and updateFile without initializing browser WASM. vi.mock('@vrowzer/fs', () => ({ - fs: { mkdirSync: vi.fn<() => void>(), writeFileSync: vi.fn<() => void>() }, + fs: { + existsSync: vi.fn<(path: string) => boolean>(() => false), + mkdirSync: vi.fn<(path: string, options?: unknown) => void>(), + writeFileSync: vi.fn<(path: string, data: unknown, options?: unknown) => void>(), + }, vol: {}, })) @@ -146,7 +151,7 @@ vi.mock('./watch', () => ({ resolveEmptyOutDir: () => false, })) -import { setupHMR, setupWorker } from './transformer' +import { setupHMR, setupWorker, updateFile } from './transformer' import { resolveConfig } from './config' import { createMessageChannelServer } from './server/ws' import { snapshotWorkerRuntimeConfig } from './worker-runtime-config' @@ -196,6 +201,43 @@ describe('setupWorker runtime config boundary', () => { }) }) +describe('virtual files', () => { + const bytes = [0x89, 0x50, 0x4e, 0x47, 0x00, 0xff] + + beforeEach(() => { + vi.mocked(fs.existsSync).mockClear() + vi.mocked(fs.mkdirSync).mockClear() + vi.mocked(fs.writeFileSync).mockClear() + }) + + test('updateFile() writes an ArrayBuffer as bytes and a string as UTF-8 text', () => { + updateFile('/assets/logo.png', new Uint8Array(bytes).buffer) + updateFile('/main.js', 'export {}') + + expect(fs.mkdirSync).toHaveBeenCalledWith('/assets', { recursive: true }) + expect(fs.writeFileSync).toHaveBeenCalledWith('/assets/logo.png', new Uint8Array(bytes)) + expect(fs.writeFileSync).toHaveBeenCalledWith('/main.js', 'export {}', { encoding: 'utf8' }) + }) + + test('setupWorker() writes each initial file as bytes or UTF-8 text by its type', async () => { + vi.mocked(resolveConfig).mockResolvedValueOnce({ + root: '/', + base: '/', + publicDir: '/public', + build: { outDir: 'dist', rollupOptions: {} }, + environments: {}, + } as unknown as Awaited>) + + await setupWorker({}, {}, { + '/public/logo.png': new Uint8Array(bytes).buffer, + '/main.js': 'export {}', + }) + + expect(fs.writeFileSync).toHaveBeenCalledWith('/public/logo.png', new Uint8Array(bytes)) + expect(fs.writeFileSync).toHaveBeenCalledWith('/main.js', 'export {}', { encoding: 'utf8' }) + }) +}) + describe('setupHMR watcher error handling', () => { test.each([ ['add', 'create'], diff --git a/packages/vite-dev-server/src/node/transformer.ts b/packages/vite-dev-server/src/node/transformer.ts index e3074430..d29e546f 100644 --- a/packages/vite-dev-server/src/node/transformer.ts +++ b/packages/vite-dev-server/src/node/transformer.ts @@ -112,6 +112,7 @@ export interface SetupWorkerOptions { * * @param inlineConfig - Vite config object or resolved config * @param options - Setup options for the worker + * @param files - Initial files. A string is written as UTF-8 text, and an ArrayBuffer as bytes. */ export async function setupWorker( inlineConfig: InlineConfig | ResolvedConfig = { @@ -125,7 +126,7 @@ export async function setupWorker( } }, options: SetupWorkerOptions = {}, - files?: Record, + files?: Record, externalWatcher?: import('#dep-types/chokidar').FSWatcher, ): Promise { // Inject client base fix plugin for HMR module re-imports @@ -410,7 +411,7 @@ export async function setupHMR(server: ViteDevServer): Promise): void { +function setupVirtualFiles(files?: Record): void { fs.mkdirSync('/public', { recursive: true }) fs.writeFileSync('/public/.gitkeep', '', { encoding: 'utf8' }) fs.writeFileSync( @@ -505,18 +506,23 @@ export function connectServiceWorkerPort( * * This ensures the file is written to the same @vrowzer/fs instance * used by DevEnvironment and the transform pipeline. + * A string is written as UTF-8 text, and an ArrayBuffer as bytes. */ -export function updateFile(path: string, content: string): void { +export function updateFile(path: string, content: string | ArrayBuffer): void { const dir = path.substring(0, path.lastIndexOf('/')) if (dir && !fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } - fs.writeFileSync(path, content, { encoding: 'utf8' }) + if (typeof content === 'string') { + fs.writeFileSync(path, content, { encoding: 'utf8' }) + } else { + fs.writeFileSync(path, new Uint8Array(content)) + } debug?.('file updated:', path) debug?.('current virtual files:', vol.toTree()) } -function updateFiles(files: Record): void { +function updateFiles(files: Record): void { for (const [path, content] of Object.entries(files)) { updateFile(path, content) } diff --git a/packages/vite-dev-server/src/shared/messages.ts b/packages/vite-dev-server/src/shared/messages.ts index e8c795d9..78c963cd 100644 --- a/packages/vite-dev-server/src/shared/messages.ts +++ b/packages/vite-dev-server/src/shared/messages.ts @@ -45,9 +45,10 @@ export interface SetupWorkerMessage { options?: Record /** * Initial files to populate the virtual filesystem (@vrowzer/fs). - * Keys are absolute paths (e.g. '/main.js'), values are file contents. + * Keys are absolute paths (e.g. '/main.js'). A string value is written as UTF-8 text, + * and an ArrayBuffer value as bytes. */ - files?: Record + files?: Record } /** From 2c3228b97a934d95750c0cadeff7b39c3d028dd4 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 1 Oct 2026 18:24:10 +0900 Subject: [PATCH 2/3] fix(vrowzer): send binary files given to ready() as bytes to both Workers initialize() treated config.files as text. The Web Worker received the ArrayBuffers in V_WW_SETUP, and the Service Worker in the text field of V_FS_INIT, so both wrote them as the string "[object ArrayBuffer]". The Service Worker now receives binary files in the binaryFiles field of V_FS_INIT, which @vrowzer/fs writes as bytes. The Web Worker writes the ArrayBuffers in V_WW_SETUP as bytes since the previous commit. With text files only, V_FS_INIT is the same as before. ready() copies the files when it is called, ArrayBuffers included, and sends the copies without a transfer list. Each Worker receives its own copy through structured cloning, and the caller's buffers stay usable. Changes that the caller makes while ready() runs no longer reach the Workers; before, the files were read later, so such changes could reach one Worker and not the other. The JSDoc of VrowzerConfig.files and the README describe the copy, and the API references are regenerated. --- packages/vrowzer/README.md | 2 + .../docs/default/interfaces/VrowzerConfig.md | 2 +- packages/vrowzer/src/index-file-sync.test.ts | 58 +++++++++++++++++++ packages/vrowzer/src/index.ts | 42 +++++++++++++- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index fe4e27d0..15bbb25f 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -176,6 +176,8 @@ const ready = await vrowzer.ready({ }) ``` +File contents can be strings or `ArrayBuffer`s. Each `ArrayBuffer` is copied for the Workers when `ready()` is called, so the caller's buffer stays usable. + #### `mount(container, options): PreviewSession` Mounts a preview iframe into the given DOM element. `options.id` is a host-defined, non-empty pane identity. Mounting the same ID again returns the existing session without moving or reloading its iframe; the first container and params remain in effect. diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md b/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md index 10d7b1c3..a1c0ce67 100644 --- a/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md +++ b/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md @@ -12,4 +12,4 @@ export interface VrowzerConfig | Name | Type | Description | | --- | --- | --- | -| `files` | `Record` | A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. | +| `files` | `Record` | A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. An ArrayBuffer is copied for the Workers when [`Vrowzer.ready`](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-ready) is called, and stays usable. | diff --git a/packages/vrowzer/src/index-file-sync.test.ts b/packages/vrowzer/src/index-file-sync.test.ts index ce67f9ae..f0bb1f3c 100644 --- a/packages/vrowzer/src/index-file-sync.test.ts +++ b/packages/vrowzer/src/index-file-sync.test.ts @@ -14,6 +14,8 @@ interface TestMessage { id?: string path?: string content?: unknown + files?: Record + binaryFiles?: Record } interface AckError { @@ -186,6 +188,62 @@ describe('Vrowzer file synchronization', () => { ]) }) + test('sends binary files to both Workers as copies, without transferring them', async () => { + const bytes = [0x89, 0x50, 0x4e, 0x47, 0x00, 0xff] + const buffer = new Uint8Array(bytes).buffer + const vrowzer = Vrowzer() + + await expect( + vrowzer.ready({ files: { '/main.js': 'export {}', '/public/logo.png': buffer } }) + ).resolves.toBe(true) + + // The Web Worker writes each file by its type + const setupFiles = workers[0]!.messages[0]!.files! + expect(setupFiles['/main.js']).toBe('export {}') + expect(setupFiles['/public/logo.png']).toBeInstanceOf(ArrayBuffer) + expect(setupFiles['/public/logo.png']).not.toBe(buffer) + expect([...new Uint8Array(setupFiles['/public/logo.png'] as ArrayBuffer)]).toEqual(bytes) + expect(workers[0]!.transfers[0]).toBeUndefined() + + // The Service Worker gets the binary files in binaryFiles + const [init, transfer] = controllerMocks.postMessage.mock.calls[0]! + expect(init).toEqual({ + type: 'V_FS_INIT', + files: { + '/main.js': 'export {}', + '/dist/client/client.mjs': 'client code', + '/dist/client/env.mjs': 'env code' + }, + binaryFiles: { '/public/logo.png': expect.any(ArrayBuffer) } + }) + const serviceWorkerCopy = (init as TestMessage).binaryFiles!['/public/logo.png']! + expect(serviceWorkerCopy).not.toBe(buffer) + expect([...new Uint8Array(serviceWorkerCopy)]).toEqual(bytes) + expect(transfer).toBeUndefined() + + // The caller's buffer is not detached + expect(buffer.byteLength).toBe(bytes.length) + expect([...new Uint8Array(buffer)]).toEqual(bytes) + }) + + test('sends the files as they were when ready() was called', async () => { + const buffer = new Uint8Array([1, 2, 3]).buffer + const files: Record = { '/data.bin': buffer } + const vrowzer = Vrowzer() + + const ready = vrowzer.ready({ files }) + new Uint8Array(buffer).fill(0) + files['/late.js'] = 'export {}' + await expect(ready).resolves.toBe(true) + + const setupFiles = workers[0]!.messages[0]!.files! + expect(Object.keys(setupFiles)).not.toContain('/late.js') + expect([...new Uint8Array(setupFiles['/data.bin'] as ArrayBuffer)]).toEqual([1, 2, 3]) + const init = controllerMocks.postMessage.mock.calls[0]![0] as TestMessage + expect(Object.keys(init.files!)).not.toContain('/late.js') + expect([...new Uint8Array(init.binaryFiles!['/data.bin']!)]).toEqual([1, 2, 3]) + }) + test('continues sending file additions, updates, and deletions to both Workers', async () => { autoAck = true const { vrowzer, worker } = await readyInstance() diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index 2709bcd3..98cb03a4 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -150,6 +150,7 @@ export interface VrowzerOptions { export interface VrowzerConfig { /** * A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. + * An ArrayBuffer is copied for the Workers when {@linkcode Vrowzer.ready} is called, and stays usable. */ files: Record } @@ -490,6 +491,38 @@ function describeFileOperation(operation: FileOperation, path: string): string { return `[Vrowzer] ${operation}(${JSON.stringify(path)})` } +/** + * Copies the files given to `ready()`, so that later changes by the caller do not reach the + * Workers. ArrayBuffers are copied as well, and the caller's buffers are never transferred. + */ +function copyInitialFiles(files: VrowzerConfig['files']): Record { + const copied: Record = { ...files } + for (const [path, content] of Object.entries(copied)) { + if (typeof content !== 'string') { + copied[path] = content.slice(0) + } + } + return copied +} + +/** + * Builds the `V_FS_INIT` message, with text files in `files` and binary files in `binaryFiles`. + */ +function createFSInitMessage(allFiles: Record): FSInitMessage { + const files: Record = {} + const binaryFiles: Record = {} + for (const [path, content] of Object.entries(allFiles)) { + if (typeof content === 'string') { + files[path] = content + } else { + binaryFiles[path] = content + } + } + return Object.keys(binaryFiles).length > 0 + ? { type: 'V_FS_INIT', files, binaryFiles } + : { type: 'V_FS_INIT', files } +} + /** * Rebuilds the error that a Worker reported in a `V_FS_ACK` message. */ @@ -1056,6 +1089,9 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { async function initialize(config: VrowzerConfig, signal: AbortSignal): Promise { try { + // The files are sent later, so copy them as they are when ready() is called + const initialFiles = copyInitialFiles(config.files) + // 1. Create Web Worker + add as publisher target webWorker = new Worker(new URL('./web-worker.ts', import.meta.url), { type: 'module' }) const currentWebWorker = webWorker @@ -1063,13 +1099,13 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { // 2. Start loading dist client files immediately. This work is included // in the single Worker setup deadline below. - let allFiles!: Record + let allFiles!: Record const allFilesReady = Promise.all([ import('@vrowzer/vite-dev-server/dist/client/client.mjs?raw'), import('@vrowzer/vite-dev-server/dist/client/env.mjs?raw') ]).then(([{ default: clientCode }, { default: envCode }]) => { allFiles = { - ...(config.files as Record), + ...initialFiles, '/dist/client/client.mjs': clientCode, '/dist/client/env.mjs': envCode } @@ -1184,7 +1220,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { publisher.addTarget(serviceWorkerTarget) // The Web Worker already loaded these files during V_WW_SETUP. // Broadcasting them again emits add events and an initial HMR reload. - serviceWorker.postMessage({ type: 'V_FS_INIT', files: allFiles } satisfies FSInitMessage) + serviceWorker.postMessage(createFSInitMessage(allFiles)) } // 8. Establish MessageChannel (Service Worker ↔ Web Worker) From f316596f2f5bec10ac961c15831dab75b5c2a48c Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 1 Oct 2026 18:26:06 +0900 Subject: [PATCH 3/3] test(vrowzer): cover binary files given to ready() in the browser The playground now passes two binary files to ready(): a public image and a file outside the public directory. The tests check that the Service Worker serves both with the same bytes, through servePublicMiddleware and serveStaticMiddleware, that the Web Worker inlines the same bytes with ?import&inline, and that the caller's ArrayBuffers keep their size and content after ready(). Without either fix, the matching test fails with the bytes of "[object ArrayBuffer]": sending the binary files in the text field of V_FS_INIT fails the Service Worker test, and writing them as UTF-8 in the Web Worker fails the Web Worker test. --- .../vrowzer/integration/playground/main.ts | 10 +++++ .../integration/vrowzer.integration-test.ts | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/vrowzer/integration/playground/main.ts b/packages/vrowzer/integration/playground/main.ts index 3c019eeb..a0a5a3c8 100644 --- a/packages/vrowzer/integration/playground/main.ts +++ b/packages/vrowzer/integration/playground/main.ts @@ -15,12 +15,22 @@ const vrowzer = Vrowzer({ }) ;(window as any).__vrowzer__ = vrowzer +// Binary files given to ready(). The tests check their bytes in both Workers, and that these +// buffers stay usable after ready(). +const initialBinaryBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff] +const initialBinaryFiles = { + '/public/initial-public.png': new Uint8Array(initialBinaryBytes).buffer, + '/initial-binary/pixel.png': new Uint8Array(initialBinaryBytes).buffer +} +;(window as any).__initialBinaryFiles__ = initialBinaryFiles + async function init() { try { status.textContent = 'Initializing vrowzer...' const ready = await vrowzer.ready({ files: { + ...initialBinaryFiles, '/public/initial-public.txt': 'initial public file', '/index.html': ` diff --git a/packages/vrowzer/integration/vrowzer.integration-test.ts b/packages/vrowzer/integration/vrowzer.integration-test.ts index a9a100dd..8e6c641a 100644 --- a/packages/vrowzer/integration/vrowzer.integration-test.ts +++ b/packages/vrowzer/integration/vrowzer.integration-test.ts @@ -784,6 +784,48 @@ if (import.meta.hot) { }) }) + describe('binary files given to ready()', () => { + // The bytes of the playground's initialBinaryFiles + const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff] + + test('serves them from the Service Worker with the same bytes', async () => { + // A public file, served by servePublicMiddleware + expect(await fetchBytesFromServiceWorker('/initial-public.png')).toEqual({ + status: 200, + bytes + }) + // A file outside the public directory, served by serveStaticMiddleware + expect(await fetchBytesFromServiceWorker('/initial-binary/pixel.png')).toEqual({ + status: 200, + bytes + }) + }) + + test('reads them in the Web Worker with the same bytes', async () => { + // The Web Worker inlines the file from its own virtual filesystem + const inlined = await fetchFromServiceWorker('/initial-binary/pixel.png?import&inline') + const base64 = inlined.body.match(/data:image\/png;base64,([A-Za-z0-9+/=]+)/)?.[1] + expect(base64).toBeDefined() + expect([...Buffer.from(base64!, 'base64')]).toEqual(bytes) + }, 30000) + + test("keeps the caller's buffers usable", async () => { + const buffers = await page.evaluate(() => + Object.entries((window as any).__initialBinaryFiles__ as Record).map( + ([path, buffer]) => ({ + path, + byteLength: buffer.byteLength, + bytes: [...new Uint8Array(buffer)] + }) + ) + ) + expect(buffers).toEqual([ + { path: '/public/initial-public.png', byteLength: bytes.length, bytes }, + { path: '/initial-binary/pixel.png', byteLength: bytes.length, bytes } + ]) + }) + }) + describe('preview sessions', () => { test('keeps multiple panes alive with targeted reload and unmount', async () => { const dangerousMarker = '\u2028\u2029'