diff --git a/packages/core/src/lib/asset-storage.test.ts b/packages/core/src/lib/asset-storage.test.ts index 432b79f900..30f7733921 100644 --- a/packages/core/src/lib/asset-storage.test.ts +++ b/packages/core/src/lib/asset-storage.test.ts @@ -1,6 +1,7 @@ import 'fake-indexeddb/auto' import { afterEach, describe, expect, test } from 'bun:test' -import { loadAssetUrl, saveAsset } from './asset-storage' +import { get, set } from 'idb-keyval' +import { ASSET_PREFIX, loadAssetUrl, saveAsset } from './asset-storage' function file(contents: string, name = 'test.txt'): File { return new File([contents], name, { type: 'text/plain' }) @@ -46,6 +47,32 @@ describe('loadAssetUrl', () => { expect(objectUrl?.startsWith('blob:')).toBe(true) }) + // Regression test: saveAsset used to put the File object itself into + // IndexedDB. Not every engine accepts File/Blob records (WebKit rejects + // them), so the record is now plain bytes plus the content type. + test('stores raw bytes and the content type, not a File object', async () => { + const url = await saveAsset(new File(['png-bytes'], 'a.png', { type: 'image/png' })) + const stored = await get<{ assetVersion: number; bytes: ArrayBuffer; type: string }>( + `${ASSET_PREFIX}${url.replace('asset://', '')}`, + ) + expect(stored).not.toBeInstanceOf(Blob) + expect(stored?.assetVersion).toBe(1) + expect(stored?.bytes).toBeInstanceOf(ArrayBuffer) + expect(new TextDecoder().decode(stored?.bytes)).toBe('png-bytes') + expect(stored?.type).toBe('image/png') + }) + + test('still reads File/Blob records written before the byte format', async () => { + await set(`${ASSET_PREFIX}legacy`, new Blob(['old'], { type: 'text/plain' })) + const objectUrl = await loadAssetUrl('asset://legacy') + expect(objectUrl?.startsWith('blob:')).toBe(true) + }) + + test('returns null instead of throwing for an unreadable record', async () => { + await set(`${ASSET_PREFIX}broken`, { unexpected: true }) + expect(await loadAssetUrl('asset://broken')).toBeNull() + }) + test('passes through blob: and http(s) URLs unchanged', async () => { expect(await loadAssetUrl('blob:http://example.com/1234')).toBe('blob:http://example.com/1234') expect(await loadAssetUrl('https://cdn.example.com/a.glb')).toBe( diff --git a/packages/core/src/lib/asset-storage.ts b/packages/core/src/lib/asset-storage.ts index 7f2213f445..9ad614e71a 100644 --- a/packages/core/src/lib/asset-storage.ts +++ b/packages/core/src/lib/asset-storage.ts @@ -9,12 +9,52 @@ const urlCache = new Map() // Unlike crypto.randomUUID(), nanoid works outside secure contexts. const nanoAssetId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) +/** + * Save a file to IndexedDB and return a custom protocol URL + */ +/** + * Stored shape for assets. Raw bytes go through the portable structured-clone + * path, which every IndexedDB implementation supports; File/Blob records do + * not (WebKit has rejected them). The content type is kept so the object URL + * served later still carries the original MIME type. + */ +export interface StoredAssetRecord { + assetVersion: 1 + bytes: ArrayBuffer + type: string +} + +function isStoredAssetRecord(value: unknown): value is StoredAssetRecord { + return ( + typeof value === 'object' && + value !== null && + (value as StoredAssetRecord).assetVersion === 1 && + (value as StoredAssetRecord).bytes instanceof ArrayBuffer && + typeof (value as StoredAssetRecord).type === 'string' + ) +} + +/** + * Turn whatever is in IndexedDB back into a Blob. Records written before the + * byte format existed are plain File/Blob objects and stay readable. + */ +function toBlob(value: unknown): Blob | null { + if (value instanceof Blob) return value + if (isStoredAssetRecord(value)) return new Blob([value.bytes], { type: value.type }) + return null +} + /** * Save a file to IndexedDB and return a custom protocol URL */ export async function saveAsset(file: File): Promise { const id = nanoAssetId() - await set(`${ASSET_PREFIX}${id}`, file) + const record: StoredAssetRecord = { + assetVersion: 1, + bytes: await file.arrayBuffer(), + type: file.type, + } + await set(`${ASSET_PREFIX}${id}`, record) return `asset://${id}` } @@ -40,12 +80,17 @@ export async function loadAssetUrl(url: string): Promise { } try { - const file = await get(`${ASSET_PREFIX}${id}`) - if (!file) { + const stored = await get(`${ASSET_PREFIX}${id}`) + if (!stored) { console.warn(`Asset not found: ${id}`) return null } - const objectUrl = URL.createObjectURL(file) + const blob = toBlob(stored) + if (!blob) { + console.warn(`Asset ${id} has an unreadable record`) + return null + } + const objectUrl = URL.createObjectURL(blob) urlCache.set(id, objectUrl) return objectUrl } catch (error) {