import { createIndexedDBFileSystem, type WorkspaceFileSystem } from 'minwebide'; import { unzipSync } from 'fflate'; import type { VfsFile } from './protocol'; // The mip "system directory": numbl's package-manager tooling plus any // packages that scripts install with `mip load --install `. It mirrors // the numbl browser IDE's __system__ store, with one difference by design: // it lives in its own IndexedDB database, shared by all projects, and is // never shown in the explorer — the mip directory is not part of a project. // // Freshness follows the numbl browser IDE: an activity stamp in // localStorage; when the system dir has been inactive longer than the wipe // interval, it is cleared and mip core is re-fetched (the mip-numbl release // tag is mutable, so this is what picks up new mip versions). Installed // packages are wiped too — scripts use `mip load --install`, which simply // reinstalls on demand. const SYSTEM_DB_NAME = 'numbl-web-ide-system'; const ACTIVITY_KEY = 'numbl-web-ide.systemLastActivity'; const INACTIVITY_WIPE_MS = 30 * 60 * 1000; const MHL_URL = 'https://github.com/mip-org/mip-core/releases/download/mip-numbl/mip-numbl-any.mhl'; const MIP_CORE_PREFIX = '/mip/packages/gh/mip-org/core/mip'; /** Search path (inside the run VFS) of the mip core package. */ export const MIP_SEARCH_PATH = `/system${MIP_CORE_PREFIX}/mip`; /** GitHub release assets have no CORS headers; rewrite through the proxy * numbl.org uses, cache-busting because the release tag is mutable. */ function proxiedUrl(url: string): string { if (/^https:\/\/github\.com\/.+\/releases\/download\/.+/.test(url)) { let proxied = url.replace('https://github.com/', 'https://mip-cors-proxy.figurl.workers.dev/gh/'); proxied += proxied.includes('?') ? '&' : '?'; proxied += 't=' + Date.now(); return proxied; } return url; } export function markSystemActivity(): void { localStorage.setItem(ACTIVITY_KEY, String(Date.now())); } function isSystemStale(): boolean { const raw = localStorage.getItem(ACTIVITY_KEY); const last = raw ? Number(raw) : 0; return !Number.isFinite(last) || Date.now() - last > INACTIVITY_WIPE_MS; } async function listFiles(fs: WorkspaceFileSystem, path = '/'): Promise<{ path: string }[]> { const result: { path: string }[] = []; const stat = await fs.fileService.resolve(fs.root.with({ path })); for (const child of stat.children ?? []) { if (child.isDirectory) { result.push(...await listFiles(fs, child.resource.path)); } else { result.push({ path: child.resource.path }); } } return result; } async function wipeSystem(fs: WorkspaceFileSystem): Promise { const stat = await fs.fileService.resolve(fs.root); for (const child of stat.children ?? []) { await fs.fileService.del(child.resource, { recursive: true }); } } async function fetchMipCore(fs: WorkspaceFileSystem): Promise { const response = await fetch(proxiedUrl(MHL_URL)); if (!response.ok) { throw new Error(`mip core download failed: HTTP ${response.status}`); } const zip = new Uint8Array(await response.arrayBuffer()); const entries = unzipSync(zip); for (const [entryPath, content] of Object.entries(entries)) { if (entryPath.endsWith('/')) { continue; // directory entry } await fs.writeFile(`${MIP_CORE_PREFIX}/${entryPath}`, content); } } export interface MipSystem { /** Resolves when mip core is installed and fresh; memoized. */ ensureCore(): Promise; /** True once ensureCore() has resolved (i.e. a run won't have to wait). */ isReady(): boolean; /** All system files, as run-VFS entries under /system/. */ collectFiles(): Promise; /** Persist a run's /system/ VFS changes back to the store. */ applyChanges(changes: { created: VfsFile[]; modified: VfsFile[]; deleted: string[] }): Promise; dispose(): void; } export async function createMipSystem(): Promise { const fs = await createIndexedDBFileSystem({ dbName: SYSTEM_DB_NAME }); let ready = false; let corePromise: Promise | undefined; const ensureCore = (): Promise => { if (!corePromise) { corePromise = (async () => { if (isSystemStale()) { await wipeSystem(fs); } const marker = fs.root.with({ path: `${MIP_CORE_PREFIX}/mip/mip.m` }); if (!(await fs.fileService.exists(marker))) { await fetchMipCore(fs); } markSystemActivity(); ready = true; })(); // a failed install should retry on the next run, not cache the error corePromise.catch(() => { corePromise = undefined; }); } return corePromise; }; return { ensureCore, isReady: () => ready, async collectFiles(): Promise { const files: VfsFile[] = []; for (const { path } of await listFiles(fs)) { const content = (await fs.fileService.readFile(fs.root.with({ path }))).value.buffer; files.push({ path: `/system${path}`, content }); } return files; }, async applyChanges(changes): Promise { for (const file of [...changes.created, ...changes.modified]) { if (file.path.startsWith('/system/')) { await fs.writeFile(file.path.slice('/system'.length), file.content); } } for (const path of changes.deleted) { if (path.startsWith('/system/')) { await fs.deleteFile(path.slice('/system'.length)); } } markSystemActivity(); }, dispose(): void { fs.dispose(); }, }; }