fix(storage): harden workspace previews

- Validate ranged thumbnail payloads and S3 bounds

- Invalidate stale previews and expose loading errors

- Document the public document workspace composable
This commit is contained in:
Danila Poyarkov 2026-08-10 20:02:58 +03:00
parent dde7376bd3
commit 4fe27dd1e6
11 changed files with 233 additions and 20 deletions

View file

@ -38,6 +38,10 @@ These are the main composables most `@open-pencil/vue` consumers will use.
- [useStrokeControls](./use-stroke-controls)
- [useEffectsControls](./use-effects-controls)
## Document workspaces
- [useDocumentWorkspace](./use-document-workspace)
## Variables, navigation, and localization
- [useVariablesEditor](./use-variables-editor)

View file

@ -0,0 +1,72 @@
---
title: useDocumentWorkspace
description: Manage document lists, refreshes, lazy previews, and workspace events.
---
# useDocumentWorkspace
`useDocumentWorkspace()` provides headless state for document browsers backed by local or remote storage.
It manages:
- initial, manual, focused-window, reconnect, and interval refreshes
- deduplicated refresh requests and source invalidation events
- lazy preview loading with bounded concurrency
- preview object URL creation and cleanup
- per-preview errors
## Usage
```ts
import { useDocumentWorkspace } from '@open-pencil/vue'
const workspace = useDocumentWorkspace({
source: {
refresh: () => documentService.list(),
loadPreview: (id) => documentService.loadPreview(id),
subscribe: (listener) => documentService.subscribe(listener),
},
refreshInterval: 60_000,
})
```
The source must return items with `id`, `name`, and `updatedAt`. A changed `updatedAt` value invalidates an existing or in-flight preview.
## Lazy previews
Apply `previewDirective` to the element that should trigger loading, then resolve the current object URL with `previewURL()`:
```vue
<script setup lang="ts">
const { documents, previewDirective: vDocumentPreview, previewURL } = workspace
</script>
<template>
<article v-for="document in documents" :key="document.id">
<div v-document-preview="document.id">
<img v-if="previewURL(document.id)" :src="previewURL(document.id) ?? undefined" alt="" />
</div>
</article>
</template>
```
When `IntersectionObserver` is unavailable, the directive loads the preview immediately.
## Errors
`error` contains the latest document-list refresh failure. Preview failures are available by document ID through `previewErrors`; use `onPreviewError` when errors should also be reported to an application service.
```ts
const workspace = useDocumentWorkspace({
source,
onPreviewError(id, error) {
reportPreviewError({ id, error })
},
})
```
Calling `loadPreview(id)` retries a failed preview. Successful loads clear the corresponding preview error.
## Cleanup
The composable revokes generated object URLs and unsubscribes from the source when its component unmounts. Call `clearPreviews()` when switching an external workspace or provider without unmounting the component.

View file

@ -14,6 +14,7 @@ export type FigThumbnailLimits = {
type ThumbnailEntry = {
method: number
compressedSize: number
outputSize: number
localOffset: number
}
@ -26,6 +27,7 @@ const DEFAULT_MAX_TAIL = 4 * 1024 * 1024
const DEFAULT_MAX_COMPRESSED = 8 * 1024 * 1024
const DEFAULT_MAX_OUTPUT = 16 * 1024 * 1024
const THUMBNAIL_NAME = 'thumbnail.png'
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function view(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
@ -43,6 +45,10 @@ function boundedLimit(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value && value > 0 ? value : fallback
}
function hasPNGSignature(bytes: Uint8Array): boolean {
return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
}
function findThumbnailEntry(
central: Uint8Array,
maxCompressed: number,
@ -66,7 +72,12 @@ function findThumbnailEntry(
const name = decoder.decode(central.subarray(offset + 46, offset + 46 + nameLength))
if (name === THUMBNAIL_NAME) {
if (compressedSize > maxCompressed || outputSize > maxOutput) return null
return { method, compressedSize, localOffset: data.getUint32(offset + 42, true) }
return {
method,
compressedSize,
outputSize,
localOffset: data.getUint32(offset + 42, true)
}
}
offset = next
}
@ -85,10 +96,14 @@ async function readEntryPayload(
entry.localOffset + 30 + headerView.getUint16(26, true) + headerView.getUint16(28, true)
if (dataStart + entry.compressedSize > reader.size) return null
const compressed = await reader.read(dataStart, dataStart + entry.compressedSize)
if (entry.method === 0) return compressed.byteLength <= maxOutput ? compressed : null
if (entry.method === 0) {
return compressed.byteLength === entry.outputSize && hasPNGSignature(compressed)
? compressed
: null
}
if (entry.method !== 8) return null
const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
return output.byteLength <= maxOutput ? output : null
return output.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null
}
/**

View file

@ -31,6 +31,7 @@ export type UseDocumentWorkspaceOptions<Item extends DocumentWorkspaceItem> = {
refreshOnReconnect?: boolean
previewConcurrency?: number
previewMimeType?: string
onPreviewError?(id: string, error: unknown): void
}
export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
@ -41,6 +42,7 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
const error = shallowRef<unknown>(null)
const lastRefreshedAt = shallowRef<Date | null>(null)
const previewUrls = ref<Record<string, string>>({})
const previewErrors = shallowRef<Record<string, unknown>>({})
const previewCleanups = new WeakMap<Element, () => void>()
const previewGenerations = new Map<string, number>()
const previewQueue: string[] = []
@ -64,9 +66,17 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
function reconcilePreviewUrls(items: readonly Item[]): void {
const previousItems = new Map(documents.value.map((item) => [item.id, item.updatedAt]))
const currentItems = new Map(items.map((item) => [item.id, item.updatedAt]))
for (const id of Object.keys(previewUrls.value)) {
if (previousItems.get(id) !== currentItems.get(id)) removePreviewURL(id)
const trackedIds = new Set([...Object.keys(previewUrls.value), ...activePreviews, ...queued])
for (const id of trackedIds) {
if (previousItems.get(id) === currentItems.get(id)) continue
removePreviewURL(id)
if (!currentItems.has(id) || activePreviews.has(id)) continue
if (!queued.has(id)) {
queued.add(id)
previewQueue.push(id)
}
}
drainPreviewQueue()
}
function clearPreviews(): void {
@ -94,6 +104,18 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
}
}
function clearPreviewError(id: string): void {
if (!(id in previewErrors.value)) return
previewErrors.value = Object.fromEntries(
Object.entries(previewErrors.value).filter(([previewId]) => previewId !== id)
)
}
function recordPreviewError(id: string, error: unknown): void {
previewErrors.value = { ...previewErrors.value, [id]: error }
options.onPreviewError?.(id, error)
}
function drainPreviewQueue(): void {
while (activePreviews.size < concurrency) {
const id = previewQueue.shift()
@ -106,13 +128,25 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
.loadPreview(id)
.then((bytes) => {
if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) {
clearPreviewError(id)
replacePreviewURL(id, bytes)
}
return undefined
})
.catch(() => null)
.catch((error: unknown) => {
if (!disposed && generation === (previewGenerations.get(id) ?? 0)) {
recordPreviewError(id, error)
}
})
.finally(() => {
activePreviews.delete(id)
if (
!disposed &&
generation !== (previewGenerations.get(id) ?? 0) &&
documents.value.some((item) => item.id === id)
) {
loadPreview(id)
}
drainPreviewQueue()
})
}
@ -120,6 +154,7 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
function loadPreview(id: string): void {
if (previewUrls.value[id] || activePreviews.has(id) || queued.has(id)) return
clearPreviewError(id)
queued.add(id)
previewQueue.push(id)
drainPreviewQueue()
@ -236,6 +271,7 @@ export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
error: readonly(error),
lastRefreshedAt: readonly(lastRefreshedAt),
previewUrls: readonly(previewUrls),
previewErrors: readonly(previewErrors),
hasDocuments: computed(() => documents.value.length > 0),
refresh,
invalidate,

View file

@ -170,7 +170,9 @@ export async function headObjectSize(
): Promise<number | null> {
const res = await s3Request(config, objectURL(config, key), { method: 'HEAD' })
if (res.status === 404) return null
const size = Number(res.headers.get('content-length'))
const sizeHeader = res.headers.get('content-length')
if (sizeHeader == null) return null
const size = Number(sizeHeader)
return Number.isSafeInteger(size) && size >= 0 ? size : null
}
@ -182,6 +184,7 @@ export async function getObjectRange(
): Promise<Uint8Array | null> {
if (
!Number.isSafeInteger(start) ||
start < 0 ||
!Number.isSafeInteger(endExclusive) ||
endExclusive <= start
) {

View file

@ -1,5 +1,7 @@
import type { StorageProviderID } from '@/app/integrations/storage/types'
export type StorageWorkspaceEvent = {
providerId: string
providerId: StorageProviderID
documentId?: string
kind: 'changed' | 'synced'
}

View file

@ -70,14 +70,15 @@ export function createStorageWorkspaceSource(
},
async loadPreview(id: string): Promise<Uint8Array | null> {
const providerID = activeStorageProviderID.value
const localStore = getLocalCanvasStore()
const local = await localStore.readThumb(id)
if (local?.byteLength) return local
const adapter = createActiveStorageAdapter()
const adapter = createActiveStorageAdapter(providerID)
if (!adapter.getThumbnail) return null
const remote = await adapter.getThumbnail(id)
if (!remote?.byteLength) return null
await localStore.writeThumb(id, remote)
if (activeStorageProviderID.value === providerID) await localStore.writeThumb(id, remote)
return remote
}
}

View file

@ -34,17 +34,26 @@ test('configured storage lists previews through ranges before opening the docume
return
}
if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) {
rangeGets++
const match = route
.request()
.headers()
.range?.match(/^bytes=(\d+)-(\d+)$/)
if (!match) {
const range = route.request().headers().range
const explicit = range?.match(/^bytes=(\d+)-(\d+)$/)
const suffix = range?.match(/^bytes=-(\d+)$/)
let start: number
let end: number
if (explicit) {
start = Number(explicit[1])
end = Math.min(Number(explicit[2]), fixture.byteLength - 1)
} else if (suffix) {
const length = Math.min(Number(suffix[1]), fixture.byteLength)
start = fixture.byteLength - length
end = fixture.byteLength - 1
} else {
await route.fulfill({ status: 416 })
return
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
await route.fulfill({ status: 416 })
return
}
const start = Number(match[1])
const end = Number(match[2])
await route.fulfill({
status: 206,
headers: {
@ -53,6 +62,7 @@ test('configured storage lists previews through ranges before opening the docume
contentType: 'application/octet-stream',
body: fixture.subarray(start, end + 1)
})
rangeGets++
return
}
if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') {
@ -91,7 +101,7 @@ test('configured storage lists previews through ranges before opening the docume
const preview = page.locator('[data-document-id="remote-1"] img')
await expect(preview).toBeVisible()
await expect(preview).toHaveAttribute('src', /^blob:/)
expect(rangeGets).toBeGreaterThan(0)
expect(rangeGets).toBe(3)
expect(fullDocumentGets).toBe(0)
await page.locator('[data-document-id="remote-1"]').click()

View file

@ -47,6 +47,10 @@ describe('local-first storage persistence', () => {
{ store, enqueueCanvas }
)
expect((await store.readThumb('canvas-preview'))?.byteLength).toBeGreaterThan(0)
const thumbnail = await store.readThumb('canvas-preview')
expect(thumbnail?.byteLength).toBeGreaterThan(0)
expect(thumbnail?.subarray(0, 8)).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
})
})

View file

@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { zipSync } from 'fflate'
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
function memoryReader(bytes: Uint8Array, ranges: Array<[number, number]>) {
@ -31,6 +33,14 @@ describe('fig ranged thumbnail extraction', () => {
)
})
test('rejects malformed thumbnail payloads', async () => {
const bytes = zipSync({
'canvas.fig': new Uint8Array([1]),
'thumbnail.png': new TextEncoder().encode('not a png')
})
expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull()
})
test('rejects thumbnails above configured output limits', async () => {
const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), {

View file

@ -158,6 +158,62 @@ describe('useDocumentWorkspace', () => {
expect(sourceListener).toBeNull()
})
test('invalidates an in-flight preview when the document changes', async () => {
const previewLoad = deferred<Uint8Array | null>()
const freshPreviewLoad = deferred<Uint8Array | null>()
const refresh = vi
.fn<() => Promise<DocumentWorkspaceItem[]>>()
.mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'first' }])
.mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'second' }])
const loadPreview = vi
.fn<() => Promise<Uint8Array | null>>()
.mockImplementationOnce(() => previewLoad.promise)
.mockImplementationOnce(() => freshPreviewLoad.promise)
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
const mounted = mountWorkspace({ refresh, loadPreview })
await flushTasks()
mounted.workspace.loadPreview('one')
expect(loadPreview).toHaveBeenCalledTimes(1)
await mounted.workspace.refresh()
previewLoad.resolve(new Uint8Array([1]))
await previewLoad.promise
await flushTasks()
expect(createObjectURL).not.toHaveBeenCalled()
expect(loadPreview).toHaveBeenCalledTimes(2)
freshPreviewLoad.resolve(new Uint8Array([2]))
await freshPreviewLoad.promise
await flushTasks()
expect(createObjectURL).toHaveBeenCalledTimes(1)
mounted.unmount()
})
test('surfaces preview failures to consumers', async () => {
const failure = new Error('preview failed')
const onPreviewError = vi.fn()
const holder: WorkspaceHolder = { current: null }
const component = defineComponent({
setup() {
holder.current = useDocumentWorkspace({
source: { refresh: async () => [], loadPreview: async () => Promise.reject(failure) },
refreshOnFocus: false,
refreshOnReconnect: false,
onPreviewError
})
return () => h('div')
}
})
const app = renderer.createApp(component)
app.mount(hostNode())
holder.current?.loadPreview('one')
await flushTasks()
expect(holder.current?.previewErrors.value.one).toBe(failure)
expect(onPreviewError).toHaveBeenCalledWith('one', failure)
app.unmount()
})
test('deduplicates previews, limits concurrency, and revokes URLs on unmount', async () => {
const loads = new Map<string, Deferred<Uint8Array | null>>()
const loadPreview = vi.fn((id: string) => {