105 lines
2.7 KiB
TypeScript
105 lines
2.7 KiB
TypeScript
export type AssetInput = {
|
|
name: string
|
|
type: string
|
|
bytes: Uint8Array
|
|
}
|
|
|
|
export type AssetRef = {
|
|
originalName: string
|
|
mimeType: string
|
|
bytes: Uint8Array
|
|
hash8: string
|
|
ext: string
|
|
path: string // assets/<hash8>.<ext>
|
|
}
|
|
|
|
export type AssetManagerOptions = {
|
|
maxBytes: number
|
|
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
|
|
}
|
|
|
|
function extOf(name: string): string {
|
|
const i = name.lastIndexOf('.')
|
|
return i >= 0 ? name.slice(i + 1).toLowerCase() : ''
|
|
}
|
|
|
|
// Simple FNV-1a 32-bit hash -> 8-hex chars
|
|
function hash8(bytes: Uint8Array): string {
|
|
let h = 0x811c9dc5 >>> 0
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
h ^= bytes[i]
|
|
h = Math.imul(h, 0x01000193) >>> 0
|
|
}
|
|
return ('00000000' + h.toString(16)).slice(-8)
|
|
}
|
|
|
|
export class AssetManager {
|
|
private readonly maxBytes: number
|
|
private readonly allowed: Set<string>
|
|
private byHash = new Map<string, AssetRef>()
|
|
private byOriginal = new Map<string, string>() // originalName -> path
|
|
private referenced = new Set<string>() // originalName referenced via rewrite
|
|
|
|
constructor(opts: AssetManagerOptions) {
|
|
this.maxBytes = opts.maxBytes
|
|
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
|
|
}
|
|
|
|
async add(file: AssetInput): Promise<AssetRef> {
|
|
const ext = extOf(file.name)
|
|
if (!this.allowed.has(ext)) {
|
|
throw new Error('Invalid extension')
|
|
}
|
|
if (file.bytes.length > this.maxBytes) {
|
|
throw new Error('File size exceeded')
|
|
}
|
|
const h = hash8(file.bytes)
|
|
const existing = this.byHash.get(h)
|
|
if (existing) {
|
|
// map original name for rewrite convenience
|
|
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, existing.path)
|
|
return existing
|
|
}
|
|
|
|
const path = `assets/${h}.${ext}`
|
|
const ref: AssetRef = {
|
|
originalName: file.name,
|
|
mimeType: file.type,
|
|
bytes: file.bytes,
|
|
hash8: h,
|
|
ext,
|
|
path,
|
|
}
|
|
this.byHash.set(h, ref)
|
|
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, path)
|
|
return ref
|
|
}
|
|
|
|
list(): AssetRef[] {
|
|
return Array.from(this.byHash.values())
|
|
}
|
|
|
|
toZipStructure(): Record<string, Uint8Array> {
|
|
const out: Record<string, Uint8Array> = {}
|
|
for (const ref of this.byHash.values()) {
|
|
out[ref.path] = ref.bytes
|
|
}
|
|
return out
|
|
}
|
|
|
|
rewriteUrl(urlOrLocalRef: string): string {
|
|
if (/^https?:\/\//i.test(urlOrLocalRef)) return urlOrLocalRef
|
|
if (urlOrLocalRef.startsWith('local:')) {
|
|
const key = urlOrLocalRef.slice('local:'.length)
|
|
const p = this.byOriginal.get(key)
|
|
if (p) this.referenced.add(key)
|
|
return p ?? urlOrLocalRef
|
|
}
|
|
return urlOrLocalRef
|
|
}
|
|
|
|
referencedList(): string[] {
|
|
return Array.from(this.referenced)
|
|
}
|
|
}
|