Files
misode.github.io/src/app/services/DataFetcher.ts
Misode c2b5529a60
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
Link directly to mojira.dev filtered issue list
2026-02-24 21:57:35 +01:00

483 lines
16 KiB
TypeScript

import config from '../Config.js'
import { Store } from '../Store.js'
import { message } from '../Utils.js'
import type { VersionId } from './Versions.js'
import { checkVersion } from './Versions.js'
const CACHE_NAME = 'misode-v2'
const CACHE_LATEST_VERSION = 'cached_latest_version'
const CACHE_PATCH = 'misode_cache_patch'
declare var __LATEST_VERSION__: string
export const latestVersion = __LATEST_VERSION__ ?? ''
const mcmetaUrl = 'https://raw.githubusercontent.com/misode/mcmeta'
const mcmetaTarballUrl = 'https://github.com/misode/mcmeta/tarball'
const vanillaMcdocUrl = 'https://raw.githubusercontent.com/SpyglassMC/vanilla-mcdoc'
const changesUrl = 'https://raw.githubusercontent.com/misode/technical-changes'
const versionDiffUrl = 'https://mcmeta-diff.misode.workers.dev'
const whatsNewUrl = 'https://whats-new.misode.workers.dev'
type McmetaTypes = 'summary' | 'data' | 'data-json' | 'assets' | 'assets-json' | 'registries' | 'atlas'
interface RefInfo {
dynamic?: boolean
ref?: string
}
function mcmeta(version: RefInfo, type: McmetaTypes, tarball?: boolean) {
return `${tarball ? mcmetaTarballUrl : mcmetaUrl}/${version.dynamic ? type : `${version.ref}-${type}`}`
}
async function validateCache(version: RefInfo) {
await applyPatches()
if (version.dynamic) {
if (localStorage.getItem(CACHE_LATEST_VERSION) !== latestVersion) {
await deleteMatching(url => url.startsWith(`${mcmetaUrl}/summary/`) || url.startsWith(`${mcmetaUrl}/data/`) || url.startsWith(`${mcmetaUrl}/assets/`) || url.startsWith(`${mcmetaUrl}/registries/`) || url.startsWith(`${mcmetaUrl}/atlas/`) || url.startsWith(`${mcmetaTarballUrl}/assets-json/`))
localStorage.setItem(CACHE_LATEST_VERSION, latestVersion)
}
version.ref = latestVersion
}
}
export function getVersionChecksum(versionId: VersionId) {
const version = config.versions.find(v => v.id === versionId)!
if (version.dynamic) {
return (localStorage.getItem(CACHE_LATEST_VERSION) ?? '').toString()
}
return version.ref
}
export interface VanillaMcdocSymbols {
ref: string,
mcdoc: Record<string, unknown>,
'mcdoc/dispatcher': Record<string, Record<string, unknown>>,
}
export async function fetchVanillaMcdoc(): Promise<VanillaMcdocSymbols> {
try {
return cachedFetch<VanillaMcdocSymbols>(`${vanillaMcdocUrl}/generated/symbols.json`, { refresh: true })
} catch (e) {
throw new Error(`Error occured while fetching vanilla-mcdoc: ${message(e)}`)
}
}
export async function fetchDependencyMcdoc(dependency: string) {
try {
return cachedFetch(`/mcdoc/${dependency}.mcdoc`, { decode: res => res.text(), refresh: true })
} catch (e) {
throw new Error(`Error occured while fetching ${dependency} mcdoc: ${message(e)}`)
}
}
export async function fetchRegistries(versionId: VersionId) {
console.debug(`[fetchRegistries] ${versionId}`)
const version = config.versions.find(v => v.id === versionId)!
await validateCache(version)
try {
const data = await cachedFetch<any>(`${mcmeta(version, 'summary')}/registries/data.min.json`)
const result = new Map<string, string[]>()
for (const id in data) {
result.set(id, data[id].map((e: string) => 'minecraft:' + e))
}
return result
} catch (e) {
throw new Error(`Error occurred while fetching registries: ${message(e)}`)
}
}
export type BlockStateData = [Record<string, string[]>, Record<string, string>]
export async function fetchBlockStates(versionId: VersionId) {
console.debug(`[fetchBlockStates] ${versionId}`)
const version = config.versions.find(v => v.id === versionId)!
const result = new Map<string, BlockStateData>()
await validateCache(version)
try {
const data = await cachedFetch<any>(`${mcmeta(version, 'summary')}/blocks/data.min.json`)
for (const id in data) {
result.set(id, data[id])
}
} catch (e) {
console.warn('Error occurred while fetching block states:', message(e))
}
return result
}
export async function fetchItemComponents(versionId: VersionId) {
console.debug(`[fetchItemComponents] ${versionId}`)
const version = config.versions.find(v => v.id === versionId)!
const result = new Map<string, Map<string, unknown>>()
if (!checkVersion(versionId, '1.20.5')) {
return result
}
await validateCache(version)
try {
const data = await cachedFetch<Record<string, Record<string, unknown>>>(`${mcmeta(version, 'summary')}/item_components/data.min.json`)
for (const [id, components] of Object.entries(data)) {
const base = new Map<string, unknown>()
if (Array.isArray(components)) { // syntax before 1.21
for (const entry of components) {
base.set(entry.type, entry.value)
}
} else {
for (const [key, value] of Object.entries(components)) {
base.set(key, value)
}
}
result.set('minecraft:' + id, base)
}
} catch (e) {
console.warn('Error occurred while fetching item components:', message(e))
}
return result
}
export async function fetchPreset(versionId: VersionId, registry: string, id: string) {
console.debug(`[fetchPreset] ${versionId} ${registry} ${id}`)
const version = config.versions.find(v => v.id === versionId)!
await validateCache(version)
try {
let url
if (id.startsWith('immersive_weathering:')) {
url = `https://raw.githubusercontent.com/AstralOrdana/Immersive-Weathering/main/src/main/resources/data/immersive_weathering/block_growths/${id.slice(21)}.json`
} else {
const type = ['atlases', 'blockstates', 'items', 'font', 'lang', 'models', 'equipment', 'post_effect'].includes(registry) ? 'assets' : 'data'
url = `${mcmeta(version, type)}/${type}/minecraft/${registry}/${id}.json`
}
const res = await fetch(url)
return await res.text()
} catch (e) {
throw new Error(`Error occurred while fetching ${registry} preset ${id}: ${message(e)}`)
}
}
export async function fetchAllPresets(versionId: VersionId, registry: string) {
console.debug(`[fetchAllPresets] ${versionId} ${registry}`)
const version = config.versions.find(v => v.id === versionId)!
await validateCache(version)
try {
const type = ['atlas', 'block_definition', 'item_definition', 'model', 'font', 'lang', 'equipment', 'post_effect'].includes(registry) ? 'assets' : 'data'
return new Map<string, unknown>(Object.entries(await cachedFetch(`${mcmeta(version, 'summary')}/${type}/${registry}/data.min.json`)))
} catch (e) {
throw new Error(`Error occurred while fetching all ${registry} presets: ${message(e)}`)
}
}
export type SoundEvents = {
[key: string]: {
sounds: (string | { name: string })[],
},
}
export async function fetchSounds(versionId: VersionId): Promise<SoundEvents> {
const version = config.versions.find(v => v.id === versionId)!
await validateCache(version)
try {
const url = `${mcmeta(version, 'summary')}/sounds/data.min.json`
return await cachedFetch(url)
} catch (e) {
throw new Error(`Error occurred while fetching sounds for ${version}: ${message(e)}`)
}
}
export function getSoundUrl(versionId: VersionId, path: string) {
const version = config.versions.find(v => v.id === versionId)!
return `${mcmeta(version, 'assets')}/assets/minecraft/sounds/${path}.ogg`
}
export type VersionMeta = {
id: string,
name: string,
release_target: string,
type: 'snapshot' | 'release',
stable: boolean,
data_version: number,
protocol_version: number,
data_pack_version: number,
data_pack_version_minor?: number,
resource_pack_version: number,
resource_pack_version_minor?: number,
build_time: string,
release_time: string,
sha1: string,
}
export async function fetchVersions(): Promise<VersionMeta[]> {
await validateCache({ dynamic: true })
try {
return cachedFetch(`${mcmeta({ dynamic: true }, 'summary')}/versions/data.min.json`, { refresh: true })
} catch (e) {
throw new Error(`Error occured while fetching versions: ${message(e)}`)
}
}
export function getAssetUrl(versionId: VersionId, type: string, path: string): string {
const version = config.versions.find(v => v.id === versionId)!
return `${mcmeta(version, 'assets')}/assets/minecraft/${type}/${path}.png`
}
export async function fetchResources(versionId: VersionId) {
const version = config.versions.find(v => v.id === versionId)!
const needsItemModels = checkVersion(versionId, '1.20.5')
const hasItemModels = checkVersion(versionId, '1.21.4')
await validateCache(version)
try {
const [blockDefinitions, models, uvMapping, atlas, itemDefinitions] = await Promise.all([
fetchAllPresets(versionId, 'block_definition'),
fetchAllPresets(versionId, 'model'),
fetch(`${mcmeta(version, 'atlas')}/all/data.min.json`).then(r => r.json()),
loadImage(`${mcmeta(version, 'atlas')}/all/atlas.png`),
// Always download the 1.21.4 item models for the version range 1.20.5 - 1.21.3
needsItemModels ? fetchAllPresets(hasItemModels ? versionId : '1.21.4', 'item_definition') : new Map<string, unknown>(),
])
return { blockDefinitions, models, uvMapping, atlas, itemDefinitions }
} catch (e) {
throw new Error(`Error occured while fetching resources: ${message(e)}`)
}
}
export async function loadImage(src: string) {
return new Promise<HTMLImageElement>(res => {
const image = new Image()
image.onload = () => res(image)
image.crossOrigin = 'Anonymous'
image.src = src
})
}
/*
async function loadImage(src: string) {
const buffer = await cachedFetch(src, { decode: r => r.arrayBuffer() })
const blob = new Blob([buffer], { type: 'image/png' })
const img = new Image()
img.src = URL.createObjectURL(blob)
return new Promise<ImageData>((res) => {
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
const imgData = ctx.getImageData(0, 0, img.width, img.height)
res(imgData)
}
})
}
*/
interface DeprecatedInfo {
removed: string[]
renamed: Record<string, string>
}
export async function fetchLanguage(versionId: VersionId, lang: string = 'en_us') {
const version = config.versions.find(v => v.id === versionId)!
await validateCache(version)
try {
const translations = await cachedFetch<Record<string, string>>(`${mcmeta(version, 'assets')}/assets/minecraft/lang/${lang}.json`)
if (checkVersion(versionId, '1.21.2')) {
const deprecated = await cachedFetch<DeprecatedInfo>(`${mcmeta(version, 'assets')}/assets/minecraft/lang/deprecated.json`)
for (const key of deprecated.removed) {
delete translations[key]
}
for (const [oldKey, newKey] of Object.entries(deprecated.renamed)) {
const value = translations[oldKey]
delete translations[oldKey]
translations[newKey] = value
}
}
return translations
} catch (e) {
throw new Error(`Error occured while fetching language: ${message(e)}`)
}
}
export interface Change {
group: string,
version: string,
order: number,
tags: string[],
content: string,
}
export async function fetchChangelogs(): Promise<Change[]> {
try {
const [changes, versions] = await Promise.all([
cachedFetch<Omit<Change, 'order'>[]>(`${changesUrl}/generated/changes.json`, { refresh: true }),
fetchVersions(),
])
const versionMap = new Map(versions.map((v, i) => [v.id, versions.length - i]))
return changes.map(c => ({ ...c, order: versionMap.get(c.version) ?? 0 }))
} catch (e) {
throw new Error(`Error occured while fetching technical changes: ${message(e)}`)
}
}
export interface GitHubCommitFile {
sha: string,
filename: string,
previous_filename?: string,
status: 'added' | 'modified' | 'removed' | 'renamed',
additions: number,
deletions: number,
changes: number,
patch: string,
}
export interface GitHubCommit {
sha: string,
html_url: string,
parents: {
sha: string,
}[],
stats: {
total: number,
additions: number,
deletions: number,
},
files: GitHubCommitFile[],
}
export async function fetchVersionDiff(version: string) {
try {
const diff = await cachedFetch<GitHubCommit>(`${versionDiffUrl}/${version}`, { refresh: true })
return diff
} catch (e) {
throw new Error(`Error occured while fetching diff for version ${version}: ${message(e)}`)
}
}
export interface WhatsNewItem {
id: string,
title: string,
body: string,
url: string,
createdAt: string,
seenAt?: string,
}
export async function fetchWhatsNew(): Promise<WhatsNewItem[]> {
try {
const whatsNew = await cachedFetch<WhatsNewItem[]>(whatsNewUrl, { refresh: true })
const seenState = Store.getWhatsNewSeen()
for (const { id, time } of seenState) {
const item = whatsNew.find(i => i.id === id)
if (item) {
item.seenAt = time
}
}
return whatsNew
} catch (e) {
throw new Error(`Error occured while fetching what's new: ${message(e)}`)
}
}
interface FetchOptions<D> {
decode?: (r: Response) => Promise<D>
refresh?: boolean
}
const REFRESHED = new Set<string>()
async function cachedFetch<D = unknown>(url: string, { decode = (r => r.json()), refresh }: FetchOptions<D> = {}): Promise<D> {
try {
const cache = await caches.open(CACHE_NAME)
console.debug(`[cachedFetch] Opened cache ${CACHE_NAME} ${url}`)
const cacheResponse = await cache.match(url)
if (refresh) {
if (REFRESHED.has(url)) {
refresh = false
} else {
REFRESHED.add(url)
}
}
if (refresh) {
try {
return await fetchAndCache(cache, url, decode, refresh)
} catch (e) {
if (cacheResponse && cacheResponse.ok) {
console.debug(`[cachedFetch] Cannot refresh, using cache ${url}`)
return await decode(cacheResponse)
}
throw new Error(`Failed to fetch: ${message(e)}`)
}
} else {
if (cacheResponse && cacheResponse.ok) {
console.debug(`[cachedFetch] Retrieving cached data ${url}`)
return await decode(cacheResponse)
}
return await fetchAndCache(cache, url, decode)
}
} catch (e: any) {
console.warn(`[cachedFetch] Failed to open cache ${CACHE_NAME}: ${e.message}`)
console.debug(`[cachedFetch] Fetching data ${url}`)
const fetchResponse = await fetch(url)
const fetchData = await decode(fetchResponse)
return fetchData
}
}
const RAWGITHUB_REGEX = /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.*)$/
async function fetchAndCache<D>(cache: Cache, url: string, decode: (r: Response) => Promise<D>, noCache?: boolean) {
console.debug(`[cachedFetch] Fetching data ${url}`)
let fetchResponse
try {
fetchResponse = await fetch(url, noCache ? { cache: 'no-cache' } : undefined)
} catch (e) {
if (url.startsWith('https://raw.githubusercontent.com/')) {
const backupUrl = url.replace(RAWGITHUB_REGEX, 'https://cdn.jsdelivr.net/gh/$1/$2@$3/$4')
console.debug(`[cachedFetch] Retrying using ${backupUrl}`)
try {
fetchResponse = await fetch(backupUrl)
} catch (e) {
throw new Error(`Backup "${backupUrl}" failed: ${message(e)}`)
}
} else {
throw e
}
}
const fetchClone = fetchResponse.clone()
const fetchData = await decode(fetchResponse)
await cache.put(url, fetchClone)
return fetchData
}
async function deleteMatching(matches: (url: string) => boolean) {
try {
const cache = await caches.open(CACHE_NAME)
console.debug(`[deleteMatching] Opened cache ${CACHE_NAME}`)
const promises: Promise<boolean>[] = []
for (const request of await cache.keys()) {
if (matches(request.url)) {
promises.push(cache.delete(request))
}
}
console.debug(`[deleteMatching] Removing ${promises.length} cache objects...`)
await Promise.all(promises)
} catch (e) {
console.warn(`[deleteMatching] Failed to open cache ${CACHE_NAME}: ${message(e)}`)
}
}
const PATCHES: (() => Promise<void>)[] = [
async () => {
['1.15', '1.16', '1.17'].forEach(v => localStorage.removeItem(`cache_${v}`));
['mcdata_master', 'vanilla_datapack_summary'].forEach(v => localStorage.removeItem(`cached_${v}`))
caches.delete('misode-v1')
},
async () => {
await deleteMatching(url => url.startsWith(`${mcmetaUrl}/1.18.2-summary/`))
},
]
async function applyPatches() {
const start = parseInt(localStorage.getItem(CACHE_PATCH) ?? '0')
for (let i = start + 1; i <= PATCHES.length; i +=1) {
const patch = PATCHES[i - 1]
if (patch) {
await patch()
}
localStorage.setItem(CACHE_PATCH, i.toFixed())
}
}