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:
parent
dde7376bd3
commit
4fe27dd1e6
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { StorageProviderID } from '@/app/integrations/storage/types'
|
||||
|
||||
export type StorageWorkspaceEvent = {
|
||||
providerId: string
|
||||
providerId: StorageProviderID
|
||||
documentId?: string
|
||||
kind: 'changed' | 'synced'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, []), {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue