Skip to content
Merged
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
48 changes: 45 additions & 3 deletions packages/vite-dev-server/src/node/transformer.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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: {},
}))

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<ReturnType<typeof resolveConfig>>)

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'],
Expand Down
16 changes: 11 additions & 5 deletions packages/vite-dev-server/src/node/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -125,7 +126,7 @@ export async function setupWorker(
}
},
options: SetupWorkerOptions = {},
files?: Record<string, string>,
files?: Record<string, string | ArrayBuffer>,
externalWatcher?: import('#dep-types/chokidar').FSWatcher,
): Promise<SetupWorkerResult> {
// Inject client base fix plugin for HMR module re-imports
Expand Down Expand Up @@ -410,7 +411,7 @@ export async function setupHMR(server: ViteDevServer): Promise<FileChangeTracker
}
}

function setupVirtualFiles(files?: Record<string, string>): void {
function setupVirtualFiles(files?: Record<string, string | ArrayBuffer>): void {
fs.mkdirSync('/public', { recursive: true })
fs.writeFileSync('/public/.gitkeep', '', { encoding: 'utf8' })
fs.writeFileSync(
Expand Down Expand Up @@ -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<string, string>): void {
function updateFiles(files: Record<string, string | ArrayBuffer>): void {
for (const [path, content] of Object.entries(files)) {
updateFile(path, content)
}
Expand Down
5 changes: 3 additions & 2 deletions packages/vite-dev-server/src/shared/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ export interface SetupWorkerMessage {
options?: Record<string, unknown>
/**
* 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<string, string>
files?: Record<string, string | ArrayBuffer>
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/vrowzer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/vrowzer/docs/default/interfaces/VrowzerConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export interface VrowzerConfig

| Name | Type | Description |
| --- | --- | --- |
| `files` | `Record<string, string \| ArrayBuffer>` | A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. |
| `files` | `Record<string, string \| ArrayBuffer>` | 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. |
10 changes: 10 additions & 0 deletions packages/vrowzer/integration/playground/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': `<!doctype html>
<html lang="en">
Expand Down
42 changes: 42 additions & 0 deletions packages/vrowzer/integration/vrowzer.integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ArrayBuffer>).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 = '</script>\u2028\u2029'
Expand Down
58 changes: 58 additions & 0 deletions packages/vrowzer/src/index-file-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface TestMessage {
id?: string
path?: string
content?: unknown
files?: Record<string, string | ArrayBuffer>
binaryFiles?: Record<string, ArrayBuffer>
}

interface AckError {
Expand Down Expand Up @@ -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<string, string | ArrayBuffer> = { '/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()
Expand Down
42 changes: 39 additions & 3 deletions packages/vrowzer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | ArrayBuffer>
}
Expand Down Expand Up @@ -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<string, string | ArrayBuffer> {
const copied: Record<string, string | ArrayBuffer> = { ...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<string, string | ArrayBuffer>): FSInitMessage {
const files: Record<string, string> = {}
const binaryFiles: Record<string, ArrayBuffer> = {}
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.
*/
Expand Down Expand Up @@ -1056,20 +1089,23 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly<Vrowzer> {

async function initialize(config: VrowzerConfig, signal: AbortSignal): Promise<boolean> {
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
publisher.addTarget(currentWebWorker)

// 2. Start loading dist client files immediately. This work is included
// in the single Worker setup deadline below.
let allFiles!: Record<string, string>
let allFiles!: Record<string, string | ArrayBuffer>
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<string, string>),
...initialFiles,
'/dist/client/client.mjs': clientCode,
'/dist/client/env.mjs': envCode
}
Expand Down Expand Up @@ -1184,7 +1220,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly<Vrowzer> {
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)
Expand Down
Loading