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
63 changes: 63 additions & 0 deletions packages/vite-dev-server/src/node/public-files-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createVirtualFSWatcher } from '@vrowzer/fs/watcher'
import { describe, expect, onTestFinished, test } from 'vite-plus/test'
import { syncPublicFiles } from './public-files-sync'

import type { FSWatcher } from '#dep-types/chokidar'

function setup(initialFiles: string[] = []) {
const watcher = createVirtualFSWatcher()
onTestFinished(() => watcher.close())
const publicFiles = new Set(initialFiles)
syncPublicFiles(watcher as unknown as FSWatcher, '/public', publicFiles)
return { watcher, publicFiles }
}

describe('syncPublicFiles', () => {
test('adds files under the public directory as soon as the event is emitted', () => {
const { watcher, publicFiles } = setup()

watcher.notify('add', '/public/logo.svg')
watcher.notify('add', '/public/images/a.png')

// No await: the list is already updated when notify() returns
expect([...publicFiles]).toEqual(['/logo.svg', '/images/a.png'])
})

test('removes deleted files', () => {
const { watcher, publicFiles } = setup(['/.gitkeep', '/logo.svg'])

watcher.notify('unlink', '/public/logo.svg')

expect([...publicFiles]).toEqual(['/.gitkeep'])
})

test('ignores files outside the public directory', () => {
const { watcher, publicFiles } = setup()

watcher.notify('add', '/src/main.ts')
// Vite's `startsWith(publicDir)` check would take this sibling for a public file
watcher.notify('add', '/publicity.js')
watcher.notify('add', '/public')
watcher.notify('add', '/public/../secret.txt')

expect([...publicFiles]).toEqual([])
})

test('keeps the list on change events', () => {
const { watcher, publicFiles } = setup(['/logo.svg'])

watcher.notify('change', '/public/logo.svg')
watcher.notify('change', '/public/new.svg')

expect([...publicFiles]).toEqual(['/logo.svg'])
})

test('normalizes paths before checking them', () => {
const { watcher, publicFiles } = setup()

watcher.notify('add', '/public/./b.svg')
watcher.notify('add', '/public/images/../c.svg')

expect([...publicFiles]).toEqual(['/b.svg', '/c.svg'])
})
})
47 changes: 47 additions & 0 deletions packages/vite-dev-server/src/node/public-files-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Keep the Service Worker's list of public files in sync with its virtual file system.
*
* Vite reads the public directory once when the dev server starts, and updates the list in the
* watcher handlers of `_createServer()`. The Service Worker's dev server does not register those
* handlers, and it receives the project files only after it has started, so without this the list
* would stay as it was at startup.
*
* @author kazuya kawaguchi (a.k.a. kazupon)
* @license MIT
*/

import { withTrailingSlash } from '../shared/utils'
import { normalizePath } from './utils'

import type { FSWatcher } from '#dep-types/chokidar'

/**
* Keeps `publicFiles` up to date with the watcher's `add` and `unlink` events, as Vite does in
* `onFileAddUnlink`. The list is updated synchronously, so it is current as soon as an event has
* been emitted.
*
* Unlike Vite, the public directory is checked with a trailing slash, so that a sibling such as
* `/publicity.js` is not taken for a public file. A module with the same path keeps its transform
* result, because the Service Worker has no module graph.
*
* @param watcher - The watcher that reports the changes of the virtual file system
* @param publicDir - The resolved public directory, e.g. `/public`
* @param publicFiles - The list that `servePublicMiddleware` checks, with paths relative to `publicDir`
*/
export function syncPublicFiles(
watcher: FSWatcher,
publicDir: string,
publicFiles: Set<string>,
): void {
const publicDirPrefix = withTrailingSlash(publicDir)
const update = (file: string, isUnlink: boolean) => {
file = normalizePath(file)
if (!file.startsWith(publicDirPrefix)) {
return
}
publicFiles[isUnlink ? 'delete' : 'add'](file.slice(publicDir.length))
}

watcher.on('add', (file) => update(file, false))
watcher.on('unlink', (file) => update(file, true))
}
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,27 @@ describe('servePublicMiddleware', () => {
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toContain('text/plain')
})

test('should follow changes to publicFiles after the middleware is created', async () => {
// getContent() returns `data.buffer`. Node slices small Buffers from a shared pool, so give
// the Buffer its own ArrayBuffer, as the Service Worker's file system does.
const content = new TextEncoder().encode('svg content')
vi.mocked(fsp.readFile).mockResolvedValue(Buffer.from(content.buffer))

const server = createMockServer()
const publicFiles = new Set<string>()
const app = createApp(servePublicMiddleware(server, publicFiles))

// The Service Worker adds public files that arrive after it has started
publicFiles.add('/added.svg')
const added = await app.request('/added.svg')
expect(added.status).toBe(200)
expect(await added.text()).toBe('svg content')

publicFiles.delete('/added.svg')
const deleted = await app.request('/added.svg')
expect(await deleted.text()).toBe('fallthrough')
})
})
})

Expand Down
9 changes: 9 additions & 0 deletions packages/vite-dev-server/src/node/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { shouldHandleViteFetch } from '../shared/serviceWorkerFetch'
import { assertBundledDevUnsupported } from './bundled-dev-guard'
import { isResolvedConfig, resolveConfig } from './config'
import { syncPublicFiles } from './public-files-sync'
import { initPublicFiles } from './publicDir'
import { baseMiddleware } from './server/middlewares/base'
import { crossOriginMiddleware } from './server/middlewares/crossOrigin'
Expand Down Expand Up @@ -421,6 +422,14 @@ export function createServer(
], resolvedWatchOptions)
: createNoopWatcher(resolvedWatchOptions)

// NOTE(kazupon): upstream updates the public file list in the watcher handlers of
// `_createServer()`, which the Service Worker does not register. The Service Worker receives the
// project files through V_FS_* messages after it has started, so keep the list in sync here. It
// is updated synchronously, before the Service Worker acknowledges the message (V_FS_ACK).
if (publicDir && publicFiles) {
syncPublicFiles(watcher, publicDir, publicFiles)
}

const closeHttpServer = createServerCloseFn(httpServer)

// birpc RPC client for delegating transform to Web Worker.
Expand Down
1 change: 0 additions & 1 deletion packages/vrowzer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,6 @@ vrowzer.reloadPreview()
- `ArrayBuffer` content is copied for each Worker, so the caller's buffer stays usable.
- The promise rejects without sending the change before `ready()` resolves to `true`, after it fails, and after `dispose()`. It also rejects when a Worker fails to apply the change (for example, a plugin's `watchChange` hook throws), when the Web Worker reports an error, when the Workers do not reply within `fileSyncTimeout`, or when the instance is disposed first. The error message names the operation, the path and the Worker.
- After a rejection, the change may be partly applied. Write the file again, or delete it, to resynchronize.
- Public files (under `/public/`) are currently not served by the Service Worker, whether they are passed to `ready()` or added later: the Service Worker builds its list of public files when it starts, before it receives any file. The promises above therefore do not make them visible.

> [!NOTE]
> Up to vrowzer 0.4.x, these methods returned `void` without waiting for the Workers, and calls made before `ready()` completed were dropped or reached only the Web Worker.
Expand Down
3 changes: 0 additions & 3 deletions packages/vrowzer/docs/default/interfaces/Vrowzer.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ when the Web Worker reports an error, when the Workers do not reply within
[VrowzerOptions.fileSyncTimeout](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-filesynctimeout), or when the instance is disposed first. The change may
be partly applied then; write the file again to resynchronize.

Public files (under `/public/`) are currently not served by the Service Worker, so they do not
become visible to preview requests.

#### Parameters

| Name | Type | Description |
Expand Down
1 change: 1 addition & 0 deletions packages/vrowzer/integration/playground/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ async function init() {

const ready = await vrowzer.ready({
files: {
'/public/initial-public.txt': 'initial public file',
'/index.html': `<!doctype html>
<html lang="en">
<head>
Expand Down
98 changes: 84 additions & 14 deletions packages/vrowzer/integration/vrowzer.integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,16 @@ async function fetchFromPreview(requestPath: string): Promise<PreviewResponse> {
* Reads a preview file through the Service Worker from the host page. Unlike the preview iframe,
* the host page is not reloaded by HMR, so a read right after a file change is not interrupted.
*/
async function fetchFromServiceWorker(requestPath: string): Promise<PreviewResponse> {
async function fetchFromServiceWorker(
requestPath: string
): Promise<PreviewResponse & { contentType: string | null }> {
return page.evaluate(async path => {
const response = await fetch(`/__preview__${path}`)
return { status: response.status, body: await response.text() }
return {
status: response.status,
body: await response.text(),
contentType: response.headers.get('Content-Type')
}
}, requestPath)
}

Expand All @@ -244,6 +250,16 @@ async function fetchBytesFromServiceWorker(
}, requestPath)
}

/**
* Runs `addFile()`, `updateFile()` or `deleteFile()` on the playground's instance and waits for it.
*/
async function runFileOperation(operation: string, args: unknown[]): Promise<void> {
await page.evaluate(({ operation, args }) => (window as any).__vrowzer__[operation](...args), {
operation,
args
})
}

/**
* Makes a test module accept its own updates, so that changing it does not reload the preview.
*/
Expand Down Expand Up @@ -627,28 +643,23 @@ if (import.meta.hot) {

test('resolves each kind of operation, and later reads follow them', async () => {
const path = '/file-sync/kinds/module.js'
const run = (operation: string, args: unknown[]) =>
page.evaluate(({ operation, args }) => (window as any).__vrowzer__[operation](...args), {
operation,
args
})

await run('addFile', [path, selfAccepting(`export const value = 'kinds-v1'`)])
await runFileOperation('addFile', [path, selfAccepting(`export const value = 'kinds-v1'`)])
expect((await fetchFromServiceWorker(path)).body).toContain('kinds-v1')

await run('updateFile', [path, selfAccepting(`export const value = 'kinds-v2'`)])
await runFileOperation('updateFile', [path, selfAccepting(`export const value = 'kinds-v2'`)])
expect((await fetchFromServiceWorker(path)).body).toContain('kinds-v2')

// Writing the same content, and adding a file that nothing imports, settle as well
await run('updateFile', [path, selfAccepting(`export const value = 'kinds-v2'`)])
await run('addFile', ['/file-sync/kinds/unused.js', 'export {}'])
await runFileOperation('updateFile', [path, selfAccepting(`export const value = 'kinds-v2'`)])
await runFileOperation('addFile', ['/file-sync/kinds/unused.js', 'export {}'])

await run('deleteFile', [path])
await runFileOperation('deleteFile', [path])
expect((await fetchFromServiceWorker(path)).body).not.toContain('kinds-v2')
// Deleting a file that no longer exists settles too
await run('deleteFile', [path])
await runFileOperation('deleteFile', [path])

await run('addFile', [path, selfAccepting(`export const value = 'kinds-v3'`)])
await runFileOperation('addFile', [path, selfAccepting(`export const value = 'kinds-v3'`)])
expect((await fetchFromServiceWorker(path)).body).toContain('kinds-v3')
}, 30000)

Expand Down Expand Up @@ -714,6 +725,65 @@ if (import.meta.hot) {
}, 30000)
})

describe('public files', () => {
test('serves a public file given to ready()', async () => {
expect(await fetchFromServiceWorker('/initial-public.txt')).toEqual({
status: 200,
body: 'initial public file',
contentType: expect.stringContaining('text/plain')
})
})

test('serves public file changes as soon as the file operations resolve', async () => {
const path = '/public/public-files/added.txt'
const url = '/public-files/added.txt'

// No polling: the promise resolves after the Service Worker has updated its public file list
await runFileOperation('addFile', [path, 'public v1'])
expect(await fetchFromServiceWorker(url)).toEqual({
status: 200,
body: 'public v1',
contentType: expect.stringContaining('text/plain')
})

await runFileOperation('updateFile', [path, 'public v2'])
expect((await fetchFromServiceWorker(url)).body).toBe('public v2')

await runFileOperation('deleteFile', [path])
expect((await fetchFromServiceWorker(url)).body).not.toContain('public v2')
})

test('serves binary public files with the same bytes', async () => {
const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]

await page.evaluate(
bytes =>
(window as any).__vrowzer__.addFile(
'/public/public-files/pixel.png',
new Uint8Array(bytes).buffer
),
bytes
)

expect(await fetchBytesFromServiceWorker('/public-files/pixel.png')).toEqual({
status: 200,
bytes
})
})

test('serves a public file instead of a module with the same path', async () => {
await addPreviewFiles({
'/public-files/precedence.js': `export const source = 'module'`,
'/public/public-files/precedence.js': `export const source = 'public'`
})

// Public files are served as-is, before the transform middleware
expect((await fetchFromServiceWorker('/public-files/precedence.js')).body).toBe(
`export const source = 'public'`
)
})
})

describe('preview sessions', () => {
test('keeps multiple panes alive with targeted reload and unmount', async () => {
const dangerousMarker = '</script>\u2028\u2029'
Expand Down
3 changes: 0 additions & 3 deletions packages/vrowzer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,6 @@ export interface Vrowzer extends Emittable<VrowzerEventMap> {
* {@link VrowzerOptions.fileSyncTimeout}, or when the instance is disposed first. The change may
* be partly applied then; write the file again to resynchronize.
*
* Public files (under `/public/`) are currently not served by the Service Worker, so they do not
* become visible to preview requests.
*
* @param filePath - The path of the file to be added.
* @param content - The content of the file, which can be a string or an ArrayBuffer. An
* ArrayBuffer is copied for the Workers and stays usable.
Expand Down
Loading