diff --git a/packages/docs/programmable/sdk/api/composables/index.md b/packages/docs/programmable/sdk/api/composables/index.md index 925e916d0..ff08b7d8f 100644 --- a/packages/docs/programmable/sdk/api/composables/index.md +++ b/packages/docs/programmable/sdk/api/composables/index.md @@ -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) diff --git a/packages/docs/programmable/sdk/api/composables/use-document-workspace.md b/packages/docs/programmable/sdk/api/composables/use-document-workspace.md new file mode 100644 index 000000000..dfca6ad1b --- /dev/null +++ b/packages/docs/programmable/sdk/api/composables/use-document-workspace.md @@ -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 + + + +``` + +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. diff --git a/packages/fig/src/thumbnail.ts b/packages/fig/src/thumbnail.ts index eb495b863..c181d7654 100644 --- a/packages/fig/src/thumbnail.ts +++ b/packages/fig/src/thumbnail.ts @@ -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 } /** diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 9745c078c..100b6f223 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -31,6 +31,7 @@ export type UseDocumentWorkspaceOptions = { refreshOnReconnect?: boolean previewConcurrency?: number previewMimeType?: string + onPreviewError?(id: string, error: unknown): void } export function useDocumentWorkspace( @@ -41,6 +42,7 @@ export function useDocumentWorkspace( const error = shallowRef(null) const lastRefreshedAt = shallowRef(null) const previewUrls = ref>({}) + const previewErrors = shallowRef>({}) const previewCleanups = new WeakMap void>() const previewGenerations = new Map() const previewQueue: string[] = [] @@ -64,9 +66,17 @@ export function useDocumentWorkspace( 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( } } + 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( .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( 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( error: readonly(error), lastRefreshedAt: readonly(lastRefreshedAt), previewUrls: readonly(previewUrls), + previewErrors: readonly(previewErrors), hasDocuments: computed(() => documents.value.length > 0), refresh, invalidate, diff --git a/src/app/integrations/storage/s3/client.ts b/src/app/integrations/storage/s3/client.ts index a13ff39ca..9b8e9f7f3 100644 --- a/src/app/integrations/storage/s3/client.ts +++ b/src/app/integrations/storage/s3/client.ts @@ -170,7 +170,9 @@ export async function headObjectSize( ): Promise { 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 { if ( !Number.isSafeInteger(start) || + start < 0 || !Number.isSafeInteger(endExclusive) || endExclusive <= start ) { diff --git a/src/app/storage/workspace/events.ts b/src/app/storage/workspace/events.ts index 47589ff98..e6d112999 100644 --- a/src/app/storage/workspace/events.ts +++ b/src/app/storage/workspace/events.ts @@ -1,5 +1,7 @@ +import type { StorageProviderID } from '@/app/integrations/storage/types' + export type StorageWorkspaceEvent = { - providerId: string + providerId: StorageProviderID documentId?: string kind: 'changed' | 'synced' } diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts index 6548ab36b..d93a50ee0 100644 --- a/src/app/storage/workspace/source.ts +++ b/src/app/storage/workspace/source.ts @@ -70,14 +70,15 @@ export function createStorageWorkspaceSource( }, async loadPreview(id: string): Promise { + 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 } } diff --git a/tests/e2e/storage/workspace.spec.ts b/tests/e2e/storage/workspace.spec.ts index 157515045..232b1ed92 100644 --- a/tests/e2e/storage/workspace.spec.ts +++ b/tests/e2e/storage/workspace.spec.ts @@ -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() diff --git a/tests/engine/app/storage/persist.test.ts b/tests/engine/app/storage/persist.test.ts index d4cc4ff8c..6061844cb 100644 --- a/tests/engine/app/storage/persist.test.ts +++ b/tests/engine/app/storage/persist.test.ts @@ -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]) + ) }) }) diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts index d652177c2..e40596715 100644 --- a/tests/engine/io/fig/thumbnail-range.test.ts +++ b/tests/engine/io/fig/thumbnail-range.test.ts @@ -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, []), { diff --git a/tests/engine/vue/document-workspace.test.ts b/tests/engine/vue/document-workspace.test.ts index a21765e63..1eb2523c5 100644 --- a/tests/engine/vue/document-workspace.test.ts +++ b/tests/engine/vue/document-workspace.test.ts @@ -158,6 +158,62 @@ describe('useDocumentWorkspace', () => { expect(sourceListener).toBeNull() }) + test('invalidates an in-flight preview when the document changes', async () => { + const previewLoad = deferred() + const freshPreviewLoad = deferred() + const refresh = vi + .fn<() => Promise>() + .mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'first' }]) + .mockResolvedValueOnce([{ id: 'one', name: 'One', updatedAt: 'second' }]) + const loadPreview = vi + .fn<() => Promise>() + .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>() const loadPreview = vi.fn((id: string) => {