243 lines
17 KiB
JavaScript
243 lines
17 KiB
JavaScript
(function(global){
|
|
'use strict';
|
|
|
|
const ARCHIVE_FORMAT='bend-field-save';
|
|
const ARCHIVE_VERSION=2;
|
|
const MAX_ARCHIVE_FILE_BYTES=1024*1024*1024;
|
|
const MAX_ARCHIVE_RAW_BYTES=2*1024*1024*1024;
|
|
const MAX_ARCHIVE_LINE_BYTES=8*1024*1024;
|
|
const MAX_ARCHIVE_EXPANSION_RATIO=100;
|
|
const MAX_BLOB_BYTES=64*1024*1024;
|
|
const PROGRESS_INTERVAL=250;
|
|
const WORKER_TARGET_BYTES=1024*1024;
|
|
const MAX_WORKER_RECORDS=32;
|
|
const ArchiveCodec=global.BendArchiveCodec||(typeof module==='object'&&module.exports?require('./archive-codec'):null);
|
|
if(!ArchiveCodec)throw new Error('BendArchiveCodec is not loaded');
|
|
const{encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine}=ArchiveCodec;
|
|
function abortError(){return new DOMException('The operation was canceled.','AbortError')}
|
|
function throwIfAborted(signal){if(signal?.aborted)throw signal.reason||abortError()}
|
|
function archiveError(message,code='INVALID_ARCHIVE'){const error=new Error(message);error.code=code;return error}
|
|
function validateManifestRecord(record,compression){
|
|
if(record?.type!=='manifest'||record.format!==ARCHIVE_FORMAT||record.archiveVersion!==ARCHIVE_VERSION)throw archiveError('This is not a supported Bend Field archive.','UNSUPPORTED_ARCHIVE');
|
|
if(record.compression!==compression)throw archiveError('The archive compression declaration does not match its contents.');
|
|
if(record.encoding!=='ndjson')throw archiveError('The archive encoding is not supported.','UNSUPPORTED_ARCHIVE');
|
|
if(!Number.isSafeInteger(record.boardCount)||record.boardCount<1)throw archiveError('The archive board count is invalid.');
|
|
if(!Number.isSafeInteger(record.estimatedRawBytes)||record.estimatedRawBytes<0||record.estimatedRawBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The archive size declaration is invalid.','ARCHIVE_TOO_LARGE');
|
|
return record;
|
|
}
|
|
function boardNumber(id){return/^B(?:0|[1-9]\d*)$/.test(id)?Number(id.slice(1)):-1}
|
|
function triggerDownload(file,name){
|
|
const url=URL.createObjectURL(file),link=document.createElement('a');
|
|
link.href=url;link.download=name;link.style.display='none';document.body.append(link);link.click();link.remove();
|
|
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
|
}
|
|
|
|
function progressReporter(callback,base={}){
|
|
let last=0;
|
|
return(patch={},force=false)=>{
|
|
if(typeof callback!=='function')return;
|
|
const now=performance.now();
|
|
if(!force&&now-last<PROGRESS_INTERVAL)return;
|
|
last=now;callback({...base,...patch});
|
|
};
|
|
}
|
|
|
|
function createArchiveCodec(){
|
|
if(typeof Worker!=='function')return null;
|
|
let worker;try{worker=new Worker('field-persistence-worker.js')}catch(_){return null}
|
|
let nextId=0;const pending=new Map();
|
|
worker.onmessage=event=>{const message=event.data||{},entry=pending.get(message.id);if(!entry)return;pending.delete(message.id);if(message.error)entry.reject(archiveError(message.error,'ARCHIVE_WORKER'));else entry.resolve(message)};
|
|
worker.onerror=event=>{const error=archiveError(event?.message||'The archive worker failed.','ARCHIVE_WORKER');for(const entry of pending.values())entry.reject(error);pending.clear()};
|
|
const request=(op,payload={})=>new Promise((resolve,reject)=>{const id=++nextId;pending.set(id,{resolve,reject});try{worker.postMessage({id,op,...payload})}catch(error){pending.delete(id);reject(error)}});
|
|
return{encode:(records,includeInCrc=true)=>request('encode',{records,includeInCrc}),parse:lines=>request('parse',{lines}),snapshot:()=>request('snapshot'),terminate(){for(const entry of pending.values())entry.reject(archiveError('The archive worker was terminated.','ARCHIVE_WORKER'));pending.clear();worker.terminate()}};
|
|
}
|
|
|
|
async function createArchiveDestination(name,estimatedBytes=0){
|
|
if(typeof global.showSaveFilePicker==='function'){
|
|
const handle=await global.showSaveFilePicker({
|
|
suggestedName:name,
|
|
types:[{description:'Bend Field save',accept:{'application/x-bend-field-save':['.bfsave']}}]
|
|
});
|
|
const writable=await handle.createWritable();
|
|
return{
|
|
kind:'file',
|
|
writable,
|
|
async finish(){},
|
|
async abort(reason){try{await writable.abort(reason)}catch(_){}}
|
|
};
|
|
}
|
|
if(global.navigator?.storage?.getDirectory){
|
|
const root=await global.navigator.storage.getDirectory(),temporaryName=`.${name}.${Date.now()}.partial`,
|
|
handle=await root.getFileHandle(temporaryName,{create:true}),writable=await handle.createWritable();
|
|
return{
|
|
kind:'opfs',
|
|
writable,
|
|
async finish(){
|
|
const file=await handle.getFile();triggerDownload(file,name);
|
|
try{await root.removeEntry(temporaryName)}catch(_){}
|
|
},
|
|
async abort(reason){
|
|
try{await writable.abort(reason)}catch(_){}
|
|
try{await root.removeEntry(temporaryName)}catch(_){}
|
|
}
|
|
};
|
|
}
|
|
if(estimatedBytes>MAX_BLOB_BYTES)throw archiveError('This browser cannot stream a save file of this size.','UNSUPPORTED_OUTPUT');
|
|
const chunks=[];let size=0,closed=false;
|
|
const writable=new WritableStream({
|
|
write(chunk){
|
|
const bytes=chunk instanceof Uint8Array?chunk:new Uint8Array(chunk);
|
|
size+=bytes.byteLength;if(size>MAX_BLOB_BYTES)throw archiveError('The save exceeded the safe in-memory download limit.','OUTPUT_TOO_LARGE');
|
|
chunks.push(bytes.slice());
|
|
},
|
|
close(){closed=true},
|
|
abort(){chunks.length=0;size=0}
|
|
});
|
|
return{
|
|
kind:'blob',
|
|
writable,
|
|
async finish(){
|
|
if(!closed)throw archiveError('The save stream did not close.','OUTPUT_INCOMPLETE');
|
|
triggerDownload(new Blob(chunks,{type:'application/x-bend-field-save'}),name);
|
|
},
|
|
async abort(){chunks.length=0;size=0}
|
|
};
|
|
}
|
|
|
|
function outputPipeline(writable,useGzip,onBytes){
|
|
const counter=new TransformStream({transform(chunk,controller){onBytes(chunk.byteLength);controller.enqueue(chunk)}});
|
|
if(useGzip){
|
|
const gzip=new CompressionStream('gzip'),pipe=gzip.readable.pipeThrough(counter).pipeTo(writable);
|
|
return{writer:gzip.writable.getWriter(),pipe};
|
|
}
|
|
const pass=new TransformStream(),pipe=pass.readable.pipeThrough(counter).pipeTo(writable);
|
|
return{writer:pass.writable.getWriter(),pipe};
|
|
}
|
|
|
|
async function writeArchive({writable,manifest,globalState,boards,signal,onProgress}){
|
|
throwIfAborted(signal);
|
|
const useGzip=manifest.compression==='gzip';
|
|
if(useGzip&&typeof CompressionStream!=='function')throw archiveError('Gzip compression is not available.','UNSUPPORTED_COMPRESSION');
|
|
let bytesWritten=0,rawBytes=0,crc=0xffffffff,crcHex='00000000',boardsDone=0,writer=null,pipe=null;
|
|
const report=progressReporter(onProgress,{boardsTotal:manifest.boardCount||0}),codec=createArchiveCodec();
|
|
try{
|
|
({writer,pipe}=outputPipeline(writable,useGzip,amount=>{bytesWritten+=amount}));
|
|
const writeRecords=async(records,includeInCrc=true)=>{
|
|
throwIfAborted(signal);if(!records.length)return;
|
|
if(codec){const result=await codec.encode(records,includeInCrc);rawBytes=result.rawBytes;crcHex=result.crc32;for(const buffer of result.chunks){throwIfAborted(signal);await writer.write(new Uint8Array(buffer))}return}
|
|
for(const record of records){const bytes=encodeRecord(record);if(includeInCrc){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength;crcHex=crc32Hex(crc)}await writer.write(bytes)}
|
|
};
|
|
report({phase:'prepare',boardsDone,bytesRead:rawBytes,bytesWritten},true);
|
|
await writeRecords([{type:'manifest',format:ARCHIVE_FORMAT,archiveVersion:ARCHIVE_VERSION,...manifest},{type:'global',value:globalState}]);
|
|
const estimatedBoardBytes=Math.max(1024,Math.ceil((Number(manifest.estimatedRawBytes)||0)/Math.max(1,manifest.boardCount||1))),batchLimit=Math.max(1,Math.min(MAX_WORKER_RECORDS,Math.floor(WORKER_TARGET_BYTES/estimatedBoardBytes)||1));let batch=[];
|
|
for await(const board of boards){throwIfAborted(signal);batch.push({type:'board',...board});boardsDone++;if(batch.length>=batchLimit){await writeRecords(batch);batch=[];report({phase:'write',boardsDone,bytesRead:rawBytes,bytesWritten})}}
|
|
await writeRecords(batch);
|
|
if(boardsDone!==manifest.boardCount)throw archiveError('The exported board count changed during export.','FIELD_CHANGED');
|
|
if(codec){const state=await codec.snapshot();rawBytes=state.rawBytes;crcHex=state.crc32}else crcHex=crc32Hex(crc);
|
|
await writeRecords([{type:'end',boardCount:boardsDone,rawBytes,crc32:crcHex}],false);
|
|
await writer.close();await pipe;report({phase:'write',boardsDone,bytesRead:rawBytes,bytesWritten},true);
|
|
return{boardCount:boardsDone,rawBytes,bytesWritten,crc32:crcHex,compression:manifest.compression,worker:codec!=null};
|
|
}catch(error){try{await writer?.abort(error)}catch(_){}try{await pipe}catch(_){}throw error}
|
|
finally{codec?.terminate()}
|
|
}
|
|
|
|
function decodedStream(file){
|
|
const source=file.stream();
|
|
return file.slice(0,2).arrayBuffer().then(buffer=>{
|
|
const bytes=new Uint8Array(buffer),gzip=bytes[0]===0x1f&&bytes[1]===0x8b;
|
|
if(gzip){
|
|
if(typeof DecompressionStream!=='function')throw archiveError('This browser cannot read gzip save files.','UNSUPPORTED_COMPRESSION');
|
|
return{stream:source.pipeThrough(new DecompressionStream('gzip')),compression:'gzip'};
|
|
}
|
|
return{stream:source,compression:'identity'};
|
|
});
|
|
}
|
|
|
|
async function firstArchiveRecord(file,signal){
|
|
if(!(file instanceof Blob))throw archiveError('No save file was selected.');
|
|
if(file.size<=0||file.size>MAX_ARCHIVE_FILE_BYTES)throw archiveError('The save file size is outside the supported range.','ARCHIVE_TOO_LARGE');
|
|
const decoded=await decodedStream(file),reader=decoded.stream.getReader(),decoder=new TextDecoder();
|
|
let text='',decodedBytes=0;
|
|
try{
|
|
while(true){
|
|
throwIfAborted(signal);
|
|
const{value,done}=await reader.read();if(done)break;
|
|
decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_LINE_BYTES)throw archiveError('The archive manifest is too large.','LINE_TOO_LARGE');
|
|
text+=decoder.decode(value,{stream:true});const newline=text.indexOf('\n');
|
|
if(newline>=0){
|
|
const line=text.slice(0,newline).replace(/\r$/,'');let record;
|
|
try{record=JSON.parse(line)}catch(_){throw archiveError('The archive manifest is not valid JSON.')}
|
|
validateManifestRecord(record,decoded.compression);return{record,compression:decoded.compression};
|
|
}
|
|
}
|
|
throw archiveError('The archive manifest is incomplete.');
|
|
}finally{try{await reader.cancel()}catch(_){}}
|
|
}
|
|
|
|
async function inspectArchive(file,{signal}={}){
|
|
const{record}=await firstArchiveRecord(file,signal);
|
|
return{
|
|
archiveVersion:record.archiveVersion,
|
|
saveSchema:record.saveSchema,
|
|
gameplayVersion:record.gameplayVersion,
|
|
worldGeneration:record.worldGeneration,
|
|
appVersion:record.appVersion,
|
|
generatorVersion:record.generatorVersion,
|
|
exportedAt:record.exportedAt,
|
|
boardCount:record.boardCount,
|
|
estimatedRawBytes:record.estimatedRawBytes,
|
|
compression:record.compression
|
|
};
|
|
}
|
|
|
|
async function readArchive(file,{signal,onProgress,onManifest,onGlobal,onBoard}={}){
|
|
if(!(file instanceof Blob))throw archiveError('No save file was selected.');
|
|
if(file.size<=0||file.size>MAX_ARCHIVE_FILE_BYTES)throw archiveError('The save file size is outside the supported range.','ARCHIVE_TOO_LARGE');
|
|
const decoded=await decodedStream(file),reader=decoded.stream.getReader(),decoder=new TextDecoder(),report=progressReporter(onProgress),codec=createArchiveCodec();
|
|
let buffer='',decodedBytes=0,rawBytes=0,crc=0xffffffff,crcHex='00000000',phase='manifest',manifest=null,globalState=null,boardsDone=0,previousBoardNumber=-1,footer=null,pendingLines=[],pendingLineBytes=0;
|
|
const processRecord=async(record,state)=>{
|
|
throwIfAborted(signal);rawBytes=state.rawBytes;crcHex=state.crc32;
|
|
if(phase==='manifest'){
|
|
validateManifestRecord(record,decoded.compression);manifest=record;phase='global';await onManifest?.(record);return;
|
|
}
|
|
if(phase==='global'){
|
|
if(record?.type!=='global'||!record.value||typeof record.value!=='object')throw archiveError('The archive global record is missing or invalid.');
|
|
globalState=record.value;phase='boards';await onGlobal?.(record.value);return;
|
|
}
|
|
if(phase!=='boards')throw archiveError('The archive contains records after its footer.');
|
|
if(record?.type==='board'){
|
|
const number=boardNumber(record.id);if(number<0||number<=previousBoardNumber)throw archiveError('Archive boards are duplicated or not in numeric order.');
|
|
if(record.id==='B0'&&boardsDone!==0)throw archiveError('B0 must be the first board.');if(boardsDone===0&&record.id!=='B0')throw archiveError('The archive does not begin with B0.');if(boardsDone>=manifest.boardCount)throw archiveError('The archive contains more boards than declared.');
|
|
previousBoardNumber=number;await onBoard?.(record,boardsDone);boardsDone++;report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0});return;
|
|
}
|
|
if(record?.type==='end'){
|
|
footer=record;phase='done';if(boardsDone!==manifest.boardCount||record.boardCount!==boardsDone)throw archiveError('The archive board count does not match its footer.');if(record.rawBytes!==state.rawBytes||String(record.crc32).toLowerCase()!==state.crc32)throw archiveError('The archive integrity check failed.','CHECKSUM_MISMATCH');return;
|
|
}
|
|
throw archiveError('The archive contains an unknown or misplaced record.');
|
|
};
|
|
const flushLines=async()=>{
|
|
if(!pendingLines.length)return;const lines=pendingLines;pendingLines=[];pendingLineBytes=0;
|
|
if(codec){const result=await codec.parse(lines);for(const item of result.items){if(item.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');await processRecord(item.record,item)}return}
|
|
for(const sourceLine of lines){let parsed;try{parsed=parseRecordLine(sourceLine)}catch(error){throw archiveError(error.message)}const{line,record,bytes:lineBytes}=parsed;if(!line)throw archiveError('The archive contains an empty record.');if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})}
|
|
};
|
|
try{
|
|
while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encodeUtf8(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}}
|
|
buffer+=decoder.decode();if(buffer){const estimatedBytes=buffer.length*3+1;if(pendingLines.length&&pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES)await flushLines();pendingLines.push(buffer);pendingLineBytes+=estimatedBytes}await flushLines();if(phase!=='done'||!footer)throw archiveError('The archive footer is missing or incomplete.');report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0},true);return{manifest,globalState,footer,boardCount:boardsDone,decodedBytes,compression:decoded.compression,worker:codec!=null};
|
|
}finally{try{reader.releaseLock()}catch(_){}codec?.terminate()}
|
|
}
|
|
|
|
async function cleanupTemporaryExports({olderThanMs=24*60*60*1000}={}){
|
|
if(!global.navigator?.storage?.getDirectory)return 0;let removed=0;const root=await global.navigator.storage.getDirectory(),cutoff=Date.now()-olderThanMs;
|
|
for await(const[name]of root.entries()){
|
|
const match=/^\.bend-field-save-.*\.(\d+)\.partial$/.exec(name);if(!match||Number(match[1])>cutoff)continue;
|
|
try{await root.removeEntry(name);removed++}catch(_){}
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
global.BendFieldPersistence=Object.freeze({
|
|
ARCHIVE_FORMAT,ARCHIVE_VERSION,MAX_ARCHIVE_FILE_BYTES,MAX_ARCHIVE_RAW_BYTES,MAX_ARCHIVE_LINE_BYTES,
|
|
MAX_ARCHIVE_EXPANSION_RATIO,MAX_BLOB_BYTES,crc32Update,crc32Hex,createArchiveDestination,
|
|
inspectArchive,writeArchive,readArchive,cleanupTemporaryExports
|
|
});
|
|
})(globalThis);
|