@@ -2,6 +2,8 @@
const DATABASE = 'tjwater-scene-assets-v1' ;
const STORE = 'resources' ;
const REQUEST _TIMEOUT _MS = 30_000 ;
const CHUNK _BYTES = 1024 * 1024 ;
const MAX _ATTEMPTS = 3 ;
function openDatabase ( ) {
return new Promise ( resolve => {
@@ -54,39 +56,111 @@ export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
if ( revision ) url . searchParams . set ( 'v' , revision ) ;
const controller = new AbortController ( ) ;
let timer ;
const resetTimeout = ( ) => {
clearTimeout ( timer ) ;
timer = setTimeout ( ( ) => controller . abort ( ) , REQUEST _TIMEOUT _MS ) ;
} ;
report ( 'download' ) ;
resetTimeout ( ) ;
let data ;
try {
const response = await fetch ( url , { signal : controller . signal , cache : 'no-cache' } ) ;
if ( ! response . ok ) throw Error ( 'HTTP ' + response . status ) ;
const total = Number ( response . headers . get ( 'content-length' ) ) || 0 ;
if ( response . body ) {
const reader = response . body . getReader ( ) ;
const chunks = [ ] ;
let loaded = 0 ;
while ( true ) {
const { done , value } = await reader . read ( ) ;
if ( done ) break ;
chunks . push ( value ) ; loaded += value . byteLength ;
report ( 'download' , loaded , total ) ; resetTimeout ( ) ;
const partialKey = key + '#partial' ;
let chunks = [ ] , loaded = 0 , total = 0 ;
if ( storage && revision ) {
try {
const partial = await transact ( storage , 'readonly' , store => store . get ( partialKey ) ) ;
if ( partial ? . revision === revision && Number . isSafeInteger ( partial . total ) && partial . total > 0 &&
Array . isArray ( partial . chunks ) && partial . chunks . length &&
partial . chunks . every ( chunk => chunk instanceof ArrayBuffer && chunk . byteLength > 0 && chunk . byteLength <= CHUNK _BYTES ) ) {
const size = partial . chunks . reduce ( ( sum , chunk ) => sum + chunk . byteLength , 0 ) ;
if ( size <= partial . total ) { chunks = partial . chunks ; loaded = size ; total = partial . total ; }
}
const bytes = new Uint8Array ( loaded ) ;
} catch { /* A denied partial cache does not prevent downloading. */ }
}
async function downloadPart ( ) {
const controller = new AbortController ( ) ;
let timer ;
const resetTimeout = ( ) => {
clearTimeout ( timer ) ;
timer = setTimeout ( ( ) => controller . abort ( ) , REQUEST _TIMEOUT _MS ) ;
} ;
resetTimeout ( ) ;
try {
const end = total ? Math . min ( loaded + CHUNK _BYTES , total ) - 1 : loaded + CHUNK _BYTES - 1 ;
const response = await fetch ( url , { signal : controller . signal , cache : 'no-cache' , headers : { Range : ` bytes= ${ loaded } - ${ end } ` } } ) ;
if ( ! response . ok ) {
const error = Error ( 'HTTP ' + response . status ) ;
error . retryable = [ 408 , 429 , 500 , 502 , 503 , 504 ] . includes ( response . status ) ;
throw error ;
}
let partTotal = 0 , expected = 0 ;
if ( response . status === 206 ) {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/ . exec ( response . headers . get ( 'content-range' ) ? ? '' ) ;
const start = Number ( range ? . [ 1 ] ) , last = Number ( range ? . [ 2 ] ) ;
partTotal = Number ( range ? . [ 3 ] ) ;
if ( ! range || start !== loaded || last < start || last > end || last >= partTotal ||
! Number . isSafeInteger ( partTotal ) || ( total && total !== partTotal ) ) {
throw Error ( '资源分段响应无效' ) ;
}
expected = last - start + 1 ;
} else {
if ( loaded ) throw Error ( '服务器未按断点返回资源' ) ;
// Servers without Range support can still return an ordinary complete response.
partTotal = Number ( response . headers . get ( 'content-length' ) ) || 0 ;
}
const buffers = [ ] ;
let size = 0 ;
if ( response . body ) {
const reader = response . body . getReader ( ) ;
try {
while ( true ) {
const { done , value } = await reader . read ( ) ;
if ( done ) break ;
buffers . push ( value ) ; size += value . byteLength ;
if ( expected && size > expected ) throw Error ( '资源分段长度无效' ) ;
report ( 'download' , loaded + size , partTotal ) ; resetTimeout ( ) ;
}
} finally { await reader . cancel ( ) . catch ( ( ) => { } ) ; }
} else {
const buffer = new Uint8Array ( await response . arrayBuffer ( ) ) ;
buffers . push ( buffer ) ; size = buffer . byteLength ;
}
if ( expected && size !== expected ) throw Error ( '资源分段不完整' ) ;
const bytes = new Uint8Array ( size ) ;
let offset = 0 ;
for ( const chunk of chunks ) { bytes . set ( chunk , offset ) ; offset += chunk . byteLength ; }
data = bytes . buffer ;
} else {
data = await response . arrayBuffer ( ) ;
for ( const buffer of buffers ) { bytes . set ( buffer , offset ) ; offset += buffer . byteLength ; }
return { data : bytes . buffer , total : response . status === 206 ? partTotal : size , ranged : response . status === 206 } ;
} catch ( error ) {
controller . abort ( ) ;
throw error ;
} finally { clearTimeout ( timer ) ; }
}
report ( 'download' , loaded , total ) ;
try {
while ( ! total || loaded < total ) {
let part ;
for ( let attempt = 0 ; attempt < MAX _ATTEMPTS ; attempt ++ ) {
try { part = await downloadPart ( ) ; break ; }
catch ( error ) {
// Permanent HTTP/contract errors stop; temporary transport/server errors can recover.
if ( ! error . retryable && ! ( error instanceof TypeError ) && error . name !== 'AbortError' && error . message !== '资源分段不完整' ) throw error ;
if ( attempt + 1 === MAX _ATTEMPTS ) throw error ;
report ( 'retry' , loaded , total ) ;
await new Promise ( resolve => setTimeout ( resolve , 300 * ( attempt + 1 ) ) ) ;
}
}
chunks . push ( part . data ) ; loaded += part . data . byteLength ; total = part . total ;
if ( storage && revision && part . ranged ) {
try { await transact ( storage , 'readwrite' , store => store . put ( { url : partialKey , revision , total , chunks } ) ) ; }
catch { /* Storage denied/full: retain the in-memory download. */ }
}
if ( ! part . ranged ) break ;
}
} catch ( error ) {
throw Error ( file + ': ' + ( controller . signal . aborted ? '下载超时,请重试' : error . message ) ) ;
} finally { clearTimeout ( timer ) ; }
throw Error ( file + ': ' + ( error . name === 'AbortError' ? '下载超时,请重试' : error . message ) ) ;
}
const bytes = new Uint8Array ( loaded ) ;
let offset = 0 ;
for ( const chunk of chunks ) { bytes . set ( new Uint8Array ( chunk ) , offset ) ; offset += chunk . byteLength ; }
const data = bytes . buffer ;
if ( storage ) {
try { await transact ( storage , 'readwrite' , store => store . delete ( partialKey ) ) ; }
catch { /* Completed bytes can still be parsed when storage is unavailable. */ }
}
report ( 'parse' , data . byteLength , data . byteLength ) ;
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.