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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"@types/bun": "^1.3.0",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"fake-indexeddb": "^6.2.5",
"typescript": "6.0.3"
},
"keywords": [
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/lib/asset-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'fake-indexeddb/auto'
import { afterEach, describe, expect, test } from 'bun:test'
import { loadAssetUrl, saveAsset } from './asset-storage'

function file(contents: string, name = 'test.txt'): File {
return new File([contents], name, { type: 'text/plain' })
}

describe('saveAsset', () => {
const originalRandomUUID = crypto.randomUUID

afterEach(() => {
crypto.randomUUID = originalRandomUUID
})

test('returns an asset:// URL', async () => {
const url = await saveAsset(file('hello'))
expect(url.startsWith('asset://')).toBe(true)
})

test('generates distinct ids across calls', async () => {
const [a, b] = await Promise.all([saveAsset(file('a')), saveAsset(file('b'))])
expect(a).not.toBe(b)
})

// Regression test: crypto.randomUUID() throws/`undefined`s on plain-HTTP
// origins because it requires a secure context (HTTPS or localhost). Every
// upload used to fail on such deployments (see packages/editor's
// reference-panel.tsx, local-guide-image.ts, both of which call saveAsset).
test('still works when crypto.randomUUID is unavailable (insecure context)', async () => {
// @ts-expect-error simulating a browser without Web Crypto's randomUUID
crypto.randomUUID = undefined

const url = await saveAsset(file('insecure-context'))
expect(url.startsWith('asset://')).toBe(true)

const loaded = await loadAssetUrl(url)
expect(loaded).not.toBeNull()
})
})

describe('loadAssetUrl', () => {
test('round-trips a saved asset back to an object URL', async () => {
const url = await saveAsset(file('round-trip'))
const objectUrl = await loadAssetUrl(url)
expect(objectUrl?.startsWith('blob:')).toBe(true)
})

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(
'https://cdn.example.com/a.glb',
)
})

test('returns null for an unknown asset id', async () => {
expect(await loadAssetUrl('asset://does-not-exist')).toBeNull()
})

test('returns null for an empty URL', async () => {
expect(await loadAssetUrl('')).toBeNull()
})
})
6 changes: 5 additions & 1 deletion packages/core/src/lib/asset-storage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { get, set } from 'idb-keyval'
import { customAlphabet } from 'nanoid'

export const ASSET_PREFIX = 'asset_data:'

// Cache for active object URLs to prevent leaks and flickering
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
*/
export async function saveAsset(file: File): Promise<string> {
const id = crypto.randomUUID()
const id = nanoAssetId()
await set(`${ASSET_PREFIX}${id}`, file)
return `asset://${id}`
}
Expand Down