diff --git a/packages/vite-dev-server/src/node/public-files-sync.test.ts b/packages/vite-dev-server/src/node/public-files-sync.test.ts new file mode 100644 index 0000000..401c89a --- /dev/null +++ b/packages/vite-dev-server/src/node/public-files-sync.test.ts @@ -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']) + }) +}) diff --git a/packages/vite-dev-server/src/node/public-files-sync.ts b/packages/vite-dev-server/src/node/public-files-sync.ts new file mode 100644 index 0000000..9a21d72 --- /dev/null +++ b/packages/vite-dev-server/src/node/public-files-sync.ts @@ -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, +): 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)) +} diff --git a/packages/vite-dev-server/src/node/server/middlewares/static.test.ts b/packages/vite-dev-server/src/node/server/middlewares/static.test.ts index d1feba2..b0f4adc 100644 --- a/packages/vite-dev-server/src/node/server/middlewares/static.test.ts +++ b/packages/vite-dev-server/src/node/server/middlewares/static.test.ts @@ -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() + 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') + }) }) }) diff --git a/packages/vite-dev-server/src/node/service-worker.ts b/packages/vite-dev-server/src/node/service-worker.ts index e399836..c1ea7b5 100644 --- a/packages/vite-dev-server/src/node/service-worker.ts +++ b/packages/vite-dev-server/src/node/service-worker.ts @@ -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' @@ -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. diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index e0c90ab..fe4e27d 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -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. diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index 7d00333..a8a3901 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -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 | diff --git a/packages/vrowzer/integration/playground/main.ts b/packages/vrowzer/integration/playground/main.ts index bd6fc77..3c019ee 100644 --- a/packages/vrowzer/integration/playground/main.ts +++ b/packages/vrowzer/integration/playground/main.ts @@ -21,6 +21,7 @@ async function init() { const ready = await vrowzer.ready({ files: { + '/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 73c9b23..a9a100d 100644 --- a/packages/vrowzer/integration/vrowzer.integration-test.ts +++ b/packages/vrowzer/integration/vrowzer.integration-test.ts @@ -228,10 +228,16 @@ async function fetchFromPreview(requestPath: string): Promise { * 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 { +async function fetchFromServiceWorker( + requestPath: string +): Promise { 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) } @@ -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 { + 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. */ @@ -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) @@ -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 = '\u2028\u2029' diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index cc658ff..2709bcd 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -338,9 +338,6 @@ export interface Vrowzer extends Emittable { * {@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.