Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/core/src/lib/asset-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 49 additions & 4 deletions packages/core/src/lib/asset-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,52 @@ const urlCache = new Map<string, string>()
// 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<string> {
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}`
}

Expand All @@ -40,12 +80,17 @@ export async function loadAssetUrl(url: string): Promise<string | null> {
}

try {
const file = await get<File | Blob>(`${ASSET_PREFIX}${id}`)
if (!file) {
const stored = await get<unknown>(`${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) {
Expand Down