load all files into memory, use change tracking map instead of object
/ release (push) Failing after 53s

This commit is contained in:
2026-04-29 22:17:13 -07:00
parent e29309e2c6
commit 8a4ef9df11
4 changed files with 136 additions and 73 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- run: npm ci
- run: npm install
- run: npm run compile
- run: git log $(git describe --tags --abbrev=0 HEAD~1)..HEAD --oneline > changelog.txt
- uses: softprops/action-gh-release@v2
+2
View File
@@ -42,6 +42,7 @@ try {
const apiKey: string = curseforge.opts().apiKey || process.env.CF_API_KEY
const pack = await getPack()
await pack.findAndReplaceCurseforgeFiles(apiKey)
await pack.savePack()
})
curseforge.command('urls')
@@ -61,6 +62,7 @@ try {
.action(async () => {
const pack = await getPack()
await pack.findAndReplaceModrinthFiles()
await pack.savePack()
})
modrith.command('merge')
+48
View File
@@ -0,0 +1,48 @@
/**
* This map tracks when entries have been changed after the map was constructed,
* and provides a method to retrieve just those changes via `getChanged()`
* and reset change tracking via `clearChanged()`
*/
export default
class ChangeTrackingMap<K, V> extends Map<K, V> {
private changedKeys=new Set<K>()
clear(): void {
for (const key of this.keys()) {
this.changedKeys.add(key)
}
super.clear()
}
delete(key: K): boolean {
const deleted=super.delete(key)
if(deleted) this.changedKeys.add(key)
return deleted
}
set(key: K, value: V): this {
super.set(key,value)
this.changedKeys.add(key)
return this
}
/**
* Constructs a new map of the changed entries in this map.
* The special value `null` is used to indicate an entry was deleted.
* @returns the change map
*/
getChanged(): Map<K, V | null> {
const changeMap=new Map<K,V|null>()
for (const key of this.changedKeys) {
if (this.has(key)) changeMap.set(key, this.get(key)!)
else changeMap.set(key, null)
}
return changeMap
}
/**
* Clears the list of changed entries,
* to allow continuing to track changes after appropriate action has been taken on previous changes,
* such as after saving changes to storage, etc.
* This does NOT restore the map to the pre-change state,
* only resets the list of changed entries.
*/
clearChanged() {
this.changedKeys=new Set()
}
}
+85 -72
View File
@@ -1,10 +1,11 @@
import type { FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemRemoveOptions, FileSystemWriteChunkType, Response } from "@sugoidogo/importable-types-web"
import type { Blob, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemRemoveOptions, FileSystemWriteChunkType, Response } from "@sugoidogo/importable-types-web"
import type { Hash, HashFormat, PackFileMetaData, PackIndex, PackMetaData, Path, Side, Url } from "./types"
import { parseTOML, stringifyTOML } from "confbox"
import { forAsync } from "./forAsync.ts"
import WorkerlessPool from "workerless"
import { CurseforgeV1Client, type Mod } from "@xmcl/curseforge"
import { ModrinthV2Client, type Project } from "@xmcl/modrinth"
import ChangeTrackingMap from "./ChangeTrackingMap.ts"
const pathSeperatorRegex = /[\\\/]/
const minecrafCurseforgeGameID = 432
@@ -109,14 +110,7 @@ export class PackwizPack {
packMetaData: PackMetaData
packIndex: PackIndex
/** This object stores the contents of {@link PackFileMetaData} files referenced in the {@link PackIndex} */
packFiles: { [key: Path]: PackFileMetaData | undefined }
/**
* This list informs the save function on what files need updates to save in-memory changes,
* allowing you to make multiple changes before incurring writes to storage.
* If you make any changes to the pack outside of the functions provided by this API,
* you will need to update this list.
*/
packFilesModified: { [key: Path]: "write" | "delete" | undefined }
packFiles: ChangeTrackingMap<Path, PackFileMetaData | Uint8Array<ArrayBuffer>>
/** Creates a new, empty modpack */
constructor(packDirectoryHandle: FileSystemDirectoryHandle, packName: string, versions: PackMetaData["versions"]) {
this.packDirectoryHandle = packDirectoryHandle
@@ -134,8 +128,7 @@ export class PackwizPack {
"hash-format": "sha1",
"files": [] as any
}
this.packFiles = {}
this.packFilesModified = {}
this.packFiles = new ChangeTrackingMap()
}
/** Fetches a modpack from an HTTP/HTTPS url */
static async fetchPack(packDirectoryHandle: FileSystemDirectoryHandle, packUrl: Url): Promise<PackwizPack> {
@@ -176,8 +169,10 @@ export class PackwizPack {
.then(response => assertOK(response).bytes())
await assertHashMatch(fileBytes, entry["hash-format"] || packIndex["hash-format"], entry.hash, entry.file)
await writeFile(fileHandle, fileBytes)
if (entry.metafile) pack.packFiles[entry.file] = parseTOML(decode(fileBytes))
if (entry.metafile) pack.packFiles.set(entry.file, parseTOML(decode(fileBytes)))
else pack.packFiles.set(entry.file, fileBytes)
})
pack.packFiles.clearChanged()
console.log("pack fetched")
return pack
}
@@ -214,8 +209,10 @@ export class PackwizPack {
if (!packMetaData.options || !packMetaData.options["no-internal-hashes"]) {
await assertHashMatch(fileBytes, entry["hash-format"] || packIndex["hash-format"], entry.hash, entry.file)
}
if (entry.metafile) pack.packFiles[entry.file] = parseTOML(decode(fileBytes))
if (entry.metafile) pack.packFiles.set(entry.file, parseTOML(decode(fileBytes)))
else pack.packFiles.set(entry.file, fileBytes)
}
pack.packFiles.clearChanged()
console.log("pack loaded")
return pack
}
@@ -260,11 +257,10 @@ export class PackwizPack {
"file": filePath,
"metafile": isMetafile
})
if (isMetafile) {
const file = await handle.getFile()
const text = await file.text()
pack.packFiles[filePath] = parseTOML(text)
}
const file = await handle.getFile()
const fileBytes = await file.bytes()
if (isMetafile) pack.packFiles.set(filePath, parseTOML(decode(fileBytes)))
else pack.packFiles.set(filePath, fileBytes)
})
} while (directoryHandles.length !== 0)
console.log("pack refreshed")
@@ -284,50 +280,53 @@ export class PackwizPack {
}
const packIndexPathSegments = this.packMetaData.index.file.split(pathSeperatorRegex)
packIndexPathSegments.pop()
const packIndexDirname = packIndexPathSegments.join("/")
const packIndexDirname = packIndexPathSegments.join("/") || "."
const changeMap = this.packFiles.getChanged()
let deletedFileCount = 0
await forAsync(Array(this.packIndex.files.length).keys(), async index => {
index -= deletedFileCount
const entry = this.packIndex.files[index]
const fileOperation = this.packFilesModified[entry.file]
delete this.packFilesModified[entry.file]
if (!fileOperation) return
let data = changeMap.get(entry.file)
if (data === undefined) return
const filePath = packIndexDirname + "/" + entry.file
if (fileOperation === "delete") {
if (data === null) {
this.packIndex.files.splice(index, 1)
deletedFileCount++
return deleteFiles(this.packDirectoryHandle, filePath)
console.debug("deleting " + filePath)
await deleteFiles(this.packDirectoryHandle, filePath)
if (!changeMap.delete(entry.file)) throw new Error("change map mismatch")
return
}
const packFileMetaData = this.packFiles[entry.file]
const fileHandle = await getFileHandle(this.packDirectoryHandle, filePath, { "create": true })
let packFileBytes: Uint8Array<ArrayBuffer>
if (packFileMetaData) {
packFileBytes = encode(stringifyTOML(packFileMetaData))
await writeFile(fileHandle, packFileBytes)
}
else {
const file = await fileHandle.getFile()
packFileBytes = await file.bytes()
}
if (!(data instanceof Uint8Array)) data = encode(stringifyTOML(data))
console.debug("writing " + filePath)
await writeFile(fileHandle, data)
if (hashes) {
entry.hash = await getHash(packFileBytes, entry["hash-format"] || this.packIndex["hash-format"])
entry.hash = await getHash(data, entry["hash-format"] || this.packIndex["hash-format"])
.then(hash => hash.toString())
} else {
delete entry.hash
}
if (!changeMap.delete(entry.file)) throw new Error("change map mismatch")
})
await forAsync(Object.keys(this.packFilesModified), async filePath => {
const packFilePath = packIndexDirname + "/" + filePath
const fileHandle = await getFileHandle(this.packDirectoryHandle, packFilePath, { 'create': true })
const packFileBytes = encode(stringifyTOML(this.packFiles[filePath]))
writeFile(fileHandle, packFileBytes)
await forAsync(changeMap.entries(), async entry => {
let [filePath, data] = entry
const fileHandle = await getFileHandle(this.packDirectoryHandle, filePath, { "create": true })
if (!(data instanceof Uint8Array)) data = encode(stringifyTOML(data))
let hash = undefined
if (hashes) {
hash = await getHash(data, entry["hash-format"] || this.packIndex["hash-format"])
.then(hash => hash.toString())
}
console.debug("writing " + filePath)
await writeFile(fileHandle, data)
this.packIndex.files.push({
"file": filePath,
"metafile": true,
"hash": hashes ? (await getHash(packFileBytes, this.packIndex["hash-format"])).toString() : undefined
"metafile": !(data instanceof Uint8Array),
"hash": hash
})
delete this.packFilesModified[filePath]
})
this.packFiles.clearChanged()
await getFileHandle(this.packDirectoryHandle, this.packMetaData.index.file, { "create": true }).then(async packIndexFileHandle => {
const packIndexBytes = encode(stringifyTOML(this.packIndex))
if (hashes) {
@@ -336,8 +335,10 @@ export class PackwizPack {
} else {
delete this.packMetaData.index.hash
}
console.debug("writing " + this.packMetaData.index.file)
await writeFile(packIndexFileHandle, packIndexBytes)
})
console.debug("writing "+this.packFileName)
await getFileHandle(this.packDirectoryHandle, this.packFileName, { "create": true })
.then(packFileHandle => writeFile(packFileHandle, encode(stringifyTOML(this.packMetaData))))
console.log("changes saved")
@@ -353,23 +354,24 @@ export class PackwizPack {
const workerlessPool = new WorkerlessPool()
const indexPathSegments = this.packMetaData.index.file.split(pathSeperatorRegex)
indexPathSegments.pop()
const indexDirname = indexPathSegments.join("/")
const indexDirectoryHandle = await getDirectoryHandle(this.packDirectoryHandle, indexDirname)
const hashPaths = new Map<number, Path>()
await forAsync(this.packIndex.files, async entry => {
if (entry.metafile) return
const fileHandle = await getFileHandle(indexDirectoryHandle, entry.file)
const file = await fileHandle.getFile()
if (file.size === 0) return
const bytes = await file.bytes()
if(!(entry.file.endsWith(".zip")||entry.file.endsWith(".jar"))) return
const bytes = this.packFiles.get(entry.file) as Uint8Array<ArrayBuffer>
const hash = await workerlessPool.run(getHash, bytes, "murmur2") as number
hashPaths.set(hash, entry.file)
})
workerlessPool.terminate()
if (hashPaths.size === 0) {
console.log("no files to check")
return this
}
console.log("requesting info on " + hashPaths.size + " files")
const curseforge = new CurseforgeV1Client(apiKey)
const result = await curseforge.getFingerprintsMatchesByGameId(minecrafCurseforgeGameID, [...hashPaths.keys()])
console.debug("got " + result.exactMatches.length + " matches")
if (result.exactMatches.length === 0) return this
const modIDs: number[] = []
for (const match of result.exactMatches) modIDs.push(match.file.modId)
const mods = new Map<number, Mod>()
@@ -382,11 +384,10 @@ export class PackwizPack {
const filePathSegments = filePath.split(pathSeperatorRegex)
const fileDirname = filePathSegments.join("/")
const newFilePath = fileDirname + "/" + mod.slug + ".pw.toml"
this.packFilesModified[filePath] = "delete"
this.packFilesModified[newFilePath] = "write"
this.packFiles.delete(filePath)
for (const hash of match.file.hashes) {
if (hash.algo === 1) {
this.packFiles[newFilePath] = {
this.packFiles.set(newFilePath, {
"filename": match.file.fileName,
"name": mod.name,
"download": {
@@ -402,16 +403,17 @@ export class PackwizPack {
},
"option": {
"optional": false,
"default": true,
"description": mod.summary
}
}
})
replacedFileCount++
break
}
}
})
console.log("replaced " + replacedFileCount + " files")
return this.savePack()
return this
}
/**
* Hashes all non-metafiles in the pack and makes a request to modrinth to find matching files.
@@ -421,18 +423,18 @@ export class PackwizPack {
console.log("finding and replacing modrinth files")
const indexPathSegments = this.packMetaData.index.file.split(pathSeperatorRegex)
indexPathSegments.pop()
const indexDirname = indexPathSegments.join("/")
const indexDirectoryHandle = await getDirectoryHandle(this.packDirectoryHandle, indexDirname)
const hashPaths = new Map<Hash, Path>()
await forAsync(this.packIndex.files, async entry => {
if (entry.metafile) return
const fileHandle = await getFileHandle(indexDirectoryHandle, entry.file)
const file = await fileHandle.getFile()
if (file.size === 0) return
const bytes = await file.bytes()
if (!(entry.file.endsWith(".zip") || entry.file.endsWith(".jar"))) return
const bytes = this.packFiles.get(entry.file) as Uint8Array<ArrayBuffer>
const hash = await getHash(bytes, "sha512") as string
hashPaths.set(hash, entry.file)
})
if (hashPaths.size === 0) {
console.log("no files to check")
return this
}
console.log("requesting info on " + hashPaths.size + " files")
const modrinth = new ModrinthV2Client()
const matches = await modrinth.getProjectVersionsByHash([...hashPaths.keys()])
@@ -441,6 +443,7 @@ export class PackwizPack {
const projects = new Map<string, Project>()
for (const project of await modrinth.getProjects(projectIDs)) projects.set(project.id, project)
console.log("got " + projectIDs.length + " matches")
if (projectIDs.length === 0) return this;
let replacedFileCount = 0
await forAsync(Object.entries(matches), async entry => {
const [hash, match] = entry
@@ -450,8 +453,7 @@ export class PackwizPack {
const filePathSegments = filePath.split(pathSeperatorRegex)
const fileDirname = filePathSegments.join("/")
const newFilePath = fileDirname + "/" + mod.slug + ".pw.toml"
this.packFilesModified[filePath] = "delete"
this.packFilesModified[newFilePath] = "write"
this.packFiles.delete(filePath)
let side: Side = "both"
if (mod.server_side === "unsupported") {
side = "client"
@@ -460,7 +462,7 @@ export class PackwizPack {
}
for (const file of match.files) {
if (file.hashes["sha512"] === hash) {
this.packFiles[newFilePath] = {
this.packFiles.set(newFilePath, {
"filename": file.filename,
"name": mod.title,
"download": {
@@ -478,13 +480,13 @@ export class PackwizPack {
"optional": false,
"description": mod.description
}
}
})
}
}
replacedFileCount++
})
console.log("replaced " + replacedFileCount + " files")
return this.savePack()
return this
}
/**
* In order to comply with curseforge API requirements, direct download URLs to curseforge files cannot be saved into the pack.
@@ -494,10 +496,15 @@ export class PackwizPack {
async cacheCurseforgeDownloadURLs(apiKey: string): Promise<PackwizPack> {
console.log("caching curseforge download URLs")
const fileIDPaths = new Map<number, Path>()
for (const [filePath, metafiledata] of Object.entries(this.packFiles)) {
for (const [filePath, metafiledata] of this.packFiles.entries()) {
if (metafiledata instanceof Uint8Array) continue
if (!metafiledata.download.mode) continue
fileIDPaths.set(metafiledata.update.curseforge["file-id"], filePath)
}
if (fileIDPaths.size === 0) {
console.log("no files to check")
return this
}
console.log("requesting info on " + fileIDPaths.size + " files")
const curseforge = new CurseforgeV1Client(apiKey)
let cachedURLcount = 0
@@ -505,9 +512,10 @@ export class PackwizPack {
if (file.downloadUrl) {
cachedURLcount++
const filePath = fileIDPaths.get(file.id)
this.packFiles[filePath].download.url = file.downloadUrl
delete this.packFiles[filePath].download.mode
this.packFilesModified[filePath] = "write"
const packFileMetaData = this.packFiles.get(filePath) as PackFileMetaData
packFileMetaData.download.url = file.downloadUrl
delete packFileMetaData.download.mode
this.packFiles.set(filePath, packFileMetaData)
}
}
console.log("cached " + cachedURLcount + " URLs")
@@ -522,10 +530,15 @@ export class PackwizPack {
async mergeModrinthMetadata(): Promise<PackwizPack> {
console.log("merging modrinth metadata with curseforge files")
const hashPaths = new Map<Hash, Path>()
for (const [filePath, metafiledata] of Object.entries(this.packFiles)) {
for (const [filePath, metafiledata] of this.packFiles.entries()) {
if (metafiledata instanceof Uint8Array) continue
if (metafiledata.update.modrinth) continue
hashPaths.set(metafiledata.download.hash, filePath)
}
if (hashPaths.size == 0) {
console.log("no files to check")
return this
}
console.log("requesting info on " + hashPaths.size + " files")
const modrinth = new ModrinthV2Client()
const matches = await modrinth.getProjectVersionsByHash([...hashPaths.keys()])
@@ -548,7 +561,7 @@ export class PackwizPack {
}
for (const file of match.files) {
if (file.hashes["sha1"] === hash) {
const metafiledata = this.packFiles[filePath]
const metafiledata = this.packFiles.get(filePath) as PackFileMetaData
delete metafiledata.download.mode
metafiledata.download.url = file.url
metafiledata.side = side
@@ -556,10 +569,10 @@ export class PackwizPack {
"mod-id": mod.id,
"version": match.id
}
this.packFiles.set(filePath, metafiledata)
break
}
}
this.packFilesModified[filePath] = "write"
}
console.log("merged " + mergedFileCount + " files")
return this