Skip to content

Commit 5e78555

Browse files
authored
fix(module): derive media list items from keys instead of N+1 fetches (#527)
* fix(module): derive media list items from keys instead of N+1 fetches host.media.list() called storage.getItem() per key on top of getKeys(), firing one HTTP request per file through the dev-mode HTTP storage driver. On a public/ folder with many files this floods the browser's per-origin connection limit and never resolves, which blocks isReady (and everything gated behind it, e.g. the floating "Edit this page" button) forever. All fields needed for listing (id, extension, stem, path, fsPath) are derivable from the storage key alone, so list() no longer needs a per-item round trip. Full metadata is still fetched lazily via host.media.get() when a specific file is opened. * fix(module): derive media key fields via shared helper, fix prod prefix mismatch list()'s key-parsing assumed every storage key was raw and unprefixed, but production's pre-baked publicAssetsStorage (templates.ts) stores items under keys already prefixed with the virtual media collection name, so list() was double-prefixing id/fsPath/path for every asset in a standard prod deploy. Extracts mediaItemFieldsFromKey() into utils/media.ts as the single place that derives id/extension/stem/path/fsPath from a raw key, used by host.ts, templates.ts, and the dev public route. Also fixes the pre-existing stem no-op (split('.').join('.') simply reconstructs the input) in all three. * fix(module): derive stem from fsPath, hoist prefix regex, add test - mediaItemFieldsFromKey: compute stem from fsPath instead of the colon-joined key, matching the existing pattern in medias/[...path].ts, so nested paths don't leak colons into stem - host.ts: hoist the collection-prefix RegExp out of the per-key map() loop - add unit tests for mediaItemFieldsFromKey
1 parent eff5a6b commit 5e78555

5 files changed

Lines changed: 65 additions & 24 deletions

File tree

src/module/src/runtime/host.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { collections } from '#content/preview'
1313
import { publicAssetsStorage, externalAssetsStorage } from '#build/studio-assets'
1414
import { useHostMeta } from './composables/useMeta'
1515
import { assignComponentsToGroups } from './utils/componentGroups'
16-
import { generateIdFromFsPath as generateMediaIdFromFsPath } from './utils/media'
16+
import { generateIdFromFsPath as generateMediaIdFromFsPath, mediaItemFieldsFromKey } from './utils/media'
17+
import { VIRTUAL_MEDIA_COLLECTION_NAME } from './utils/constants'
1718
import { getCollectionSourceById } from './utils/source'
1819
import { kebabCase } from 'scule'
1920

@@ -318,13 +319,13 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
318319
return await getStorage().getItem(generateMediaIdFromFsPath(fsPath)) as MediaItem
319320
},
320321
list: async (): Promise<MediaItem[]> => {
321-
const storage = getStorage()
322-
const items = await Promise.all(
323-
await storage.getKeys().then((keys: string[]) =>
324-
keys.map((key: string) => storage.getItem(key)),
325-
),
326-
)
327-
return items.filter(Boolean) as MediaItem[]
322+
const keys = await getStorage().getKeys()
323+
// production's pre-baked storage keys carry the collection prefix; dev/external keys don't
324+
const collectionPrefix = new RegExp(`^${VIRTUAL_MEDIA_COLLECTION_NAME}:`)
325+
return keys.map((key: string): MediaItem => {
326+
const rawKey = key.replace(collectionPrefix, '')
327+
return mediaItemFieldsFromKey(rawKey)
328+
})
328329
},
329330
upsert: async (fsPath: string, media: MediaItem) => {
330331
const id = generateMediaIdFromFsPath(fsPath)

src/module/src/runtime/server/routes/dev/public/[...path].ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { H3Event } from 'h3'
22
import { createError, eventHandler, getRequestHeader, readRawBody, setResponseHeader } from 'h3'
33
import type { Storage, StorageMeta } from 'unstorage'
4-
import { withLeadingSlash } from 'ufo'
54
// @ts-expect-error useStorage is not defined in .nuxt/imports.d.ts
65
import { useStorage } from '#imports'
76
import { VIRTUAL_MEDIA_COLLECTION_NAME } from '../../../../utils/constants'
7+
import { mediaItemFieldsFromKey } from '../../../../utils/media'
88

99

1010
export default eventHandler(async (event) => {
@@ -29,11 +29,7 @@ export default eventHandler(async (event) => {
2929
})
3030
}
3131
return {
32-
id: `${VIRTUAL_MEDIA_COLLECTION_NAME}/${key.replace(/:/g, '/')}`,
33-
extension: key.split('.').pop(),
34-
stem: key.split('.').join('.'),
35-
path: '/' + key.replace(/:/g, '/'),
36-
fsPath: withLeadingSlash(key.replace(/:/g, '/')),
32+
...mediaItemFieldsFromKey(key),
3733
version: new Date(item.mtime || new Date()).getTime(),
3834
}
3935
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
11
import { join } from 'pathe'
2+
import { withLeadingSlash } from 'ufo'
23
import { VIRTUAL_MEDIA_COLLECTION_NAME } from './constants'
34

45
export function generateIdFromFsPath(fsPath: string) {
56
return join(VIRTUAL_MEDIA_COLLECTION_NAME, fsPath)
67
}
8+
9+
export interface MediaItemKeyFields {
10+
id: string
11+
extension: string
12+
stem: string
13+
path: string
14+
fsPath: string
15+
[key: string]: unknown
16+
}
17+
18+
// `key` must be a raw, unprefixed storage key — strip VIRTUAL_MEDIA_COLLECTION_NAME first if present
19+
export function mediaItemFieldsFromKey(key: string): MediaItemKeyFields {
20+
const fsPath = withLeadingSlash(key.replace(/:/g, '/'))
21+
return {
22+
id: generateIdFromFsPath(fsPath),
23+
extension: key.split('.').pop() || '',
24+
stem: fsPath.split('.').slice(0, -1).join('.'),
25+
path: fsPath,
26+
fsPath,
27+
}
28+
}

src/module/src/templates.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Storage } from 'unstorage'
2-
import { withLeadingSlash } from 'ufo'
3-
import { VIRTUAL_MEDIA_COLLECTION_NAME } from './utils/constants'
2+
import { mediaItemFieldsFromKey } from './runtime/utils/media'
43

54
export async function getAssetsDefaultStorageDevTemplate() {
65
return [
@@ -20,14 +19,7 @@ export async function getAssetsDefaultStorageTemplate(assetsStorage: Storage) {
2019
'const storage = createStorage({})',
2120
'',
2221
...keys.map((key) => {
23-
const path = withLeadingSlash(key.replace(/:/g, '/'))
24-
const value = {
25-
id: `${VIRTUAL_MEDIA_COLLECTION_NAME}/${key.replace(/:/g, '/')}`,
26-
extension: key.split('.').pop(),
27-
stem: key.split('.').join('.'),
28-
path,
29-
fsPath: path,
30-
}
22+
const value = mediaItemFieldsFromKey(key)
3123
return `storage.setItem('${value.id}', ${JSON.stringify(value)})`
3224
}),
3325
'',
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { mediaItemFieldsFromKey } from '../../src/runtime/utils/media'
3+
4+
describe('mediaItemFieldsFromKey', () => {
5+
it('should derive fields from a root-level key', () => {
6+
expect(mediaItemFieldsFromKey('demo.mp4')).toEqual({
7+
id: 'public-assets/demo.mp4',
8+
extension: 'mp4',
9+
stem: '/demo',
10+
path: '/demo.mp4',
11+
fsPath: '/demo.mp4',
12+
})
13+
})
14+
15+
it('should derive fields from a nested, colon-separated key', () => {
16+
expect(mediaItemFieldsFromKey('videos:sub:demo.mp4')).toEqual({
17+
id: 'public-assets/videos/sub/demo.mp4',
18+
extension: 'mp4',
19+
stem: '/videos/sub/demo',
20+
path: '/videos/sub/demo.mp4',
21+
fsPath: '/videos/sub/demo.mp4',
22+
})
23+
})
24+
25+
it('should not duplicate the extension in stem for a file with a single dot', () => {
26+
const { stem, extension } = mediaItemFieldsFromKey('photo.png')
27+
28+
expect(`${stem}.${extension}`).toBe('/photo.png')
29+
})
30+
})

0 commit comments

Comments
 (0)