import { createHash } from 'node:crypto'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import JSZip from 'jszip'; import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract'; import { inlineHtmlAssets } from '@/lib/export/inline-assets'; import type { Scene, Stage } from '@/lib/types/stage'; export type MakeloreCoursePackage = { bytes: Uint8Array; contentHash: string; archiveSha256: string; archiveBytes: number; sceneCount: number; title: string; language?: string; capabilities: { sceneKinds: Array<'slide' | 'interactive' | 'quiz' | 'pbl'>; hasAudio: boolean; hasWhiteboard: boolean; hasAgent: boolean; }; classroom: { stage: Stage; scenes: Scene[] }; }; const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex'); export async function packageUniqueMakeloreCourse(input: { courseId: string; stage: Stage; scenes: Scene[]; fetchImpl?: typeof fetch; }): Promise { const scenes = await Promise.all( input.scenes.map(async (scene) => { if (scene.content.type !== 'interactive' || !scene.content.html) return scene; const inlined = await inlineHtmlAssets(scene.content.html, { fetchImpl: input.fetchImpl, keepImportmapFallbacks: false, }); if (inlined.report.failed.length || inlined.unresolved.length) { throw new Error(`Interactive scene ${scene.id} is not offline-complete`); } return { ...scene, content: { ...scene.content, html: inlined.html } } as Scene; }), ); const classroom = { stage: input.stage, scenes }; const knowledge = extractKnowledgePack(input.courseId, input.stage, scenes); const quiz = extractQuizPack(scenes); const classroomJson = JSON.stringify(classroom); const knowledgeJson = JSON.stringify(knowledge); const quizJson = JSON.stringify(quiz); const contentHash = sha256( JSON.stringify([ ['classroom.json', sha256(classroomJson)], ['knowledge/knowledge.json', sha256(knowledgeJson)], ['quiz/quiz.json', sha256(quizJson)], ]), ); const sceneKinds = [...new Set(scenes.map((scene) => scene.type))].filter( (kind): kind is 'slide' | 'interactive' | 'quiz' | 'pbl' => ['slide', 'interactive', 'quiz', 'pbl'].includes(kind), ); const capabilities = { sceneKinds, hasAudio: scenes.some((scene) => scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)), ), hasWhiteboard: Boolean( input.stage.whiteboard?.length || scenes.some((scene) => Boolean(scene.whiteboards?.length)), ), hasAgent: Boolean(input.stage.generatedAgentConfigs?.length), }; const descriptor = { schemaVersion: 1, kind: 'makelore-course-package', courseId: input.courseId, contentHash, formatVersion: 1, minPlayerVersion: '2.0.0', title: input.stage.name, language: input.stage.languageDirective, sceneCount: scenes.length, capabilities, createdAt: new Date().toISOString(), }; const zip = new JSZip(); zip.file('descriptor.json', JSON.stringify(descriptor, null, 2)); zip.file('classroom.json', classroomJson); zip.file('knowledge/knowledge.json', knowledgeJson); zip.file('quiz/quiz.json', quizJson); const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); return { bytes, contentHash, archiveSha256: sha256(bytes), archiveBytes: bytes.byteLength, sceneCount: scenes.length, title: input.stage.name, language: input.stage.languageDirective, capabilities, classroom, }; } function packageRoot(): string { const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data'); return resolve( process.env.LEARNING_COURSE_PACKAGE_DIR || process.env.MAKELORE_COURSE_PACKAGE_DIR || resolve(dataRoot, 'makelore-packages'), ); } function courseStoreRoot(): string { const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data'); return resolve( process.env.LEARNING_COURSE_STORE_DIR || process.env.MAKELORE_COURSE_STORE_DIR || resolve(dataRoot, 'makelore-courses'), ); } function assertLearningCourseId(courseId: string): void { if (!/^[A-Za-z0-9_-]{1,64}$/.test(courseId)) throw new Error('Invalid learning course id'); } export async function persistUniqueMakeloreCourse( courseId: string, course: MakeloreCoursePackage, ): Promise { const packages = packageRoot(); const records = courseStoreRoot(); await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]); const packagePath = resolve(packages, `${courseId}.zip`); const packageTemp = `${packagePath}.${process.pid}.tmp`; const recordPath = resolve(records, `${courseId}.json`); const recordTemp = `${recordPath}.${process.pid}.tmp`; await writeFile(packageTemp, course.bytes); await writeFile( recordTemp, JSON.stringify( { schemaVersion: 1, courseId, contentHash: course.contentHash, title: course.title, language: course.language, classroom: course.classroom, }, null, 2, ), ); await rename(packageTemp, packagePath); await rename(recordTemp, recordPath); return packagePath; } export async function readUniqueMakeloreCoursePackage( courseId: string, ): Promise { assertLearningCourseId(courseId); try { return new Uint8Array(await readFile(resolve(packageRoot(), `${courseId}.zip`))); } catch { return null; } } export interface FrozenLearningPackageRecord { schemaVersion: 1 | 2; courseId: string; mode: 'single' | 'large'; sourceJobId: string; /** * Stable digest of the exact mutable source snapshot used to build this * archive. Legacy records omit it and must never be reused solely by job id. */ sourceDigest?: string; contentHash: string; archiveSha256: string; archiveBytes: number; formatVersion: number; minPlayerVersion: string; title: string; language?: string; sceneCount: number; moduleCount: number; capabilities: Record; courseStructure?: Record; classroom?: { stage: Stage; scenes: Scene[] }; createdAt: string; } /** Persist exact frozen bytes plus the metadata Works uses for idempotent finalize. */ export async function persistFrozenLearningPackage( record: FrozenLearningPackageRecord, bytes: Uint8Array, ): Promise { assertLearningCourseId(record.courseId); const packages = packageRoot(); const records = courseStoreRoot(); await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]); const packagePath = resolve(packages, `${record.courseId}.zip`); const recordPath = resolve(records, `${record.courseId}.json`); const suffix = `${process.pid}.${Date.now()}.tmp`; const packageTemp = `${packagePath}.${suffix}`; const recordTemp = `${recordPath}.${suffix}`; await writeFile(packageTemp, bytes); await writeFile(recordTemp, JSON.stringify(record, null, 2)); await rename(packageTemp, packagePath); await rename(recordTemp, recordPath); } export async function readFrozenLearningPackageRecord( courseId: string, ): Promise { assertLearningCourseId(courseId); try { const value = JSON.parse( await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'), ) as Partial; if ( value.courseId !== courseId || (value.mode !== 'single' && value.mode !== 'large') || (value.sourceDigest !== undefined && !/^[0-9a-f]{64}$/.test(value.sourceDigest)) || typeof value.contentHash !== 'string' || !/^[0-9a-f]{64}$/.test(value.contentHash) || typeof value.archiveSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.archiveSha256) || typeof value.archiveBytes !== 'number' ) { return null; } return value as FrozenLearningPackageRecord; } catch { return null; } }