diff --git a/packages/vite-dev-server/src/node/transformer.test.ts b/packages/vite-dev-server/src/node/transformer.test.ts index 7ef8900..d847fe5 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 e307443..d29e546 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 e8c795d..78c963c 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 } /** diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index fe4e27d..15bbb25 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 10d7b1c..a1c0ce6 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/integration/playground/main.ts b/packages/vrowzer/integration/playground/main.ts index 3c019ee..a0a5a3c 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 a9a100d..8e6c641 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' diff --git a/packages/vrowzer/src/index-file-sync.test.ts b/packages/vrowzer/src/index-file-sync.test.ts index ce67f9a..f0bb1f3 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 2709bcd..98cb03a 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)