603 lines
23 KiB
TypeScript
603 lines
23 KiB
TypeScript
// Frozen courseware packager — turns a classroom payload (stage + scenes)
|
|
// into an immutable, versioned, self-contained bundle ZIP.
|
|
//
|
|
// The packager is isomorphic: it runs in the browser (ops workbench, bytes
|
|
// from IndexedDB) and on the server (publish route, bytes from the asset
|
|
// registry) — bytes always arrive through injected resolvers. The only global
|
|
// it needs beyond plain JS is WebCrypto (`crypto.subtle`), which both browsers
|
|
// and Node ≥19 provide.
|
|
//
|
|
// Publish policy ("freeze"): the bundle is complete only when every speech
|
|
// action resolves to narration bytes, every media record resolves to bytes,
|
|
// and interactive HTML inlines without failures. `requireComplete` turns any
|
|
// gap into a hard error — the publish route should pass `true` so a broken
|
|
// courseware can never be published.
|
|
|
|
import { inlineHtmlAssets, type InlineReport } from '@/lib/export/inline-assets';
|
|
import type { SpeechAction } from '@/lib/types/action';
|
|
import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage';
|
|
import type { MediaFileRecord } from '@/lib/utils/database';
|
|
import { buildManifest } from './serialize';
|
|
import { extractKnowledgePack, extractQuizPack } from './extract';
|
|
import {
|
|
FROZEN_BUNDLE_CONTENT_HASH_VERSION,
|
|
FROZEN_BUNDLE_FORMAT_VERSION,
|
|
FROZEN_BUNDLE_KIND,
|
|
FROZEN_BUNDLE_KNOWLEDGE_FILE,
|
|
FROZEN_BUNDLE_MANIFEST_FILE,
|
|
FROZEN_BUNDLE_QUIZ_FILE,
|
|
type CompletenessReport,
|
|
type FrozenBundleMeta,
|
|
type KnowledgePack,
|
|
type MissingResource,
|
|
type QuizPack,
|
|
} from './types';
|
|
|
|
export interface PackageSources {
|
|
coursewareId: string;
|
|
/**
|
|
* Publish version claim written into `bundle.json`. Server-side builders pass
|
|
* the version allocated under the registry lock. Browser packaging leaves
|
|
* this unset (defaults to 1); an unversioned upload is treated as a template
|
|
* and the registry stamps the allocated version into the stored ZIP.
|
|
*/
|
|
version?: number;
|
|
stage: Stage;
|
|
scenes: Scene[];
|
|
agentConfigs?: GeneratedAgentConfig[];
|
|
/** Media records to ship (caller-owned listing, e.g. asset registry). */
|
|
mediaRecords?: MediaFileRecord[];
|
|
/** Resolve narration bytes by audioId. Absent audioId → missing resource. */
|
|
resolveAudioBytes?: (audioId: string) => Promise<Blob | null>;
|
|
/** Resolve media bytes by record. Absent record → missing resource. */
|
|
resolveMediaBytes?: (record: MediaFileRecord) => Promise<Blob | null>;
|
|
/**
|
|
* Optional scene preparation hook (e.g. PBL scenes → design templates before
|
|
* persistence). Defaults to identity — the ops end wires its real prep.
|
|
*/
|
|
prepareScenes?: (stageId: string, scenes: readonly Scene[]) => Promise<Scene[]>;
|
|
/** Fetch implementation for inlining interactive HTML assets. Default: global fetch. */
|
|
fetchImpl?: typeof fetch;
|
|
publishedAt?: string;
|
|
appVersion?: string;
|
|
/** Fail the whole publish on any missing resource. Default false (report only). */
|
|
requireComplete?: boolean;
|
|
/** Require generated bytes for narration scripts. Disable only when TTS was intentionally off. */
|
|
requireNarrationAudio?: boolean;
|
|
/** Require at least one portable agent persona snapshot. */
|
|
requireAgentRoster?: boolean;
|
|
/** Require at least one interactive Scene with generated, non-empty HTML. */
|
|
requireInteractiveHtml?: boolean;
|
|
/** Remove online import-map fallbacks and reject every residual HTML asset URL. */
|
|
strictInteractiveAssets?: boolean;
|
|
}
|
|
|
|
export interface PackageResult {
|
|
zip: Blob;
|
|
contentHash: string;
|
|
entryCount: number;
|
|
manifest: ReturnType<typeof buildManifest>;
|
|
meta: FrozenBundleMeta;
|
|
completeness: CompletenessReport;
|
|
quiz: QuizPack;
|
|
knowledge: KnowledgePack;
|
|
inlineReport: InlineReport;
|
|
}
|
|
|
|
interface CollectedAudio {
|
|
path: string;
|
|
bytes: Uint8Array;
|
|
sha: string;
|
|
ext: string;
|
|
audioId: string;
|
|
}
|
|
|
|
interface CollectedMedia {
|
|
path: string;
|
|
bytes: Uint8Array;
|
|
sha: string;
|
|
ext: string;
|
|
elementId: string;
|
|
posterPath?: string;
|
|
posterBytes?: Uint8Array;
|
|
record: MediaFileRecord;
|
|
}
|
|
|
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
// Isomorphic: WebCrypto exists in browsers and Node ≥19. The cast covers the
|
|
// TS 5.7 `Uint8Array<ArrayBufferLike>` → `BufferSource` mismatch; callers
|
|
// always hold real ArrayBuffer-backed views (jszip output / arrayBuffer()).
|
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource);
|
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
|
|
function contentHashVersion(value: unknown): 1 | typeof FROZEN_BUNDLE_CONTENT_HASH_VERSION {
|
|
if (value === undefined) return 1;
|
|
if (value === 1 || value === FROZEN_BUNDLE_CONTENT_HASH_VERSION) return value;
|
|
throw new Error(`Unsupported frozen bundle content hash version: ${String(value)}`);
|
|
}
|
|
|
|
/** Hash one payload file under the selected backward-compatible algorithm. */
|
|
async function hashPayloadFile(
|
|
name: string,
|
|
bytes: Uint8Array,
|
|
version: 1 | typeof FROZEN_BUNDLE_CONTENT_HASH_VERSION,
|
|
): Promise<string> {
|
|
if (version >= 2 && name === 'manifest.json') {
|
|
const parsed = JSON.parse(decoder.decode(bytes)) as Record<string, unknown>;
|
|
// exportedAt records transport time, not classroom content. Normalizing it
|
|
// makes a lost-response retry package the same immutable payload identity.
|
|
delete parsed.exportedAt;
|
|
return sha256Hex(encoder.encode(JSON.stringify(parsed)));
|
|
}
|
|
return sha256Hex(bytes);
|
|
}
|
|
|
|
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
|
|
return new Uint8Array(await blob.arrayBuffer());
|
|
}
|
|
|
|
function missing(
|
|
kind: MissingResource['kind'],
|
|
ref: string,
|
|
reason: string,
|
|
sceneId?: string,
|
|
): MissingResource {
|
|
return { kind, ref, reason, ...(sceneId ? { sceneId } : {}) };
|
|
}
|
|
|
|
function residualExecutableNetworkDependencies(html: string): string[] {
|
|
const dependencies = new Set<string>();
|
|
for (const match of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
const script = match[1] ?? '';
|
|
for (const url of script.match(/https?:\/\/[^\s'"`<>\\)]+/gi) ?? []) {
|
|
dependencies.add(url);
|
|
}
|
|
// Inlining pyodide.js does not inline its wasm/stdlib downloads. Until the
|
|
// player ships a frozen local Pyodide distribution, treating loadPyodide
|
|
// as complete would create a package that predictably fails under the
|
|
// offline CSP (`connect-src 'none'`).
|
|
if (/\bloadPyodide\s*\(/.test(script)) dependencies.add('dynamic:loadPyodide');
|
|
}
|
|
return [...dependencies];
|
|
}
|
|
|
|
/**
|
|
* Package a classroom into a frozen bundle. See module doc for policy.
|
|
*/
|
|
export async function packageCourseware(sources: PackageSources): Promise<PackageResult> {
|
|
const JSZip = (await import('jszip')).default;
|
|
const zip = new JSZip();
|
|
|
|
const {
|
|
coursewareId,
|
|
version = 1,
|
|
stage,
|
|
scenes: rawScenes,
|
|
mediaRecords = [],
|
|
resolveAudioBytes,
|
|
resolveMediaBytes,
|
|
prepareScenes,
|
|
fetchImpl,
|
|
publishedAt = new Date().toISOString(),
|
|
appVersion = '0.0.0',
|
|
requireComplete = false,
|
|
requireNarrationAudio = true,
|
|
requireAgentRoster = false,
|
|
requireInteractiveHtml = false,
|
|
strictInteractiveAssets = false,
|
|
} = sources;
|
|
|
|
const scenes = prepareScenes ? await prepareScenes(stage.id, rawScenes) : [...rawScenes];
|
|
const agentConfigs = sources.agentConfigs ?? stage.generatedAgentConfigs ?? [];
|
|
if (requireAgentRoster && agentConfigs.length === 0) {
|
|
throw new Error(`Frozen bundle has no portable agent roster (${coursewareId} v${version})`);
|
|
}
|
|
const missingResources: MissingResource[] = [];
|
|
|
|
// ── 1. Collect narration audio ─────────────────────────────────────────
|
|
const audioIdToPath = new Map<string, string>();
|
|
const collectedAudio: CollectedAudio[] = [];
|
|
for (const scene of scenes) {
|
|
for (const action of scene.actions ?? []) {
|
|
if (action.type !== 'speech') continue;
|
|
const speech = action as SpeechAction;
|
|
if (!speech.audioId) {
|
|
if (requireNarrationAudio) {
|
|
missingResources.push(
|
|
missing(
|
|
'audio',
|
|
`${scene.id}:${speech.text.slice(0, 40)}`,
|
|
'speech action without audioId',
|
|
scene.id,
|
|
),
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (audioIdToPath.has(speech.audioId)) continue;
|
|
const record = await resolveAudioBytes?.(speech.audioId);
|
|
if (!record) {
|
|
if (requireNarrationAudio) {
|
|
missingResources.push(missing('audio', speech.audioId, 'no narration bytes', scene.id));
|
|
}
|
|
continue;
|
|
}
|
|
const ext = record.type.split('/')[1]?.replace('mpeg', 'mp3') || 'mp3';
|
|
const path = `audio/${speech.audioId}.${ext}`;
|
|
const bytes = await blobToBytes(record);
|
|
audioIdToPath.set(speech.audioId, path);
|
|
collectedAudio.push({
|
|
path,
|
|
bytes,
|
|
sha: await sha256Hex(bytes),
|
|
ext,
|
|
audioId: speech.audioId,
|
|
});
|
|
zip.file(path, bytes);
|
|
}
|
|
}
|
|
|
|
// ── 2. Collect media ───────────────────────────────────────────────────
|
|
const collectedMedia: CollectedMedia[] = [];
|
|
for (const record of mediaRecords) {
|
|
if (record.error) {
|
|
missingResources.push(missing('media', record.id, 'media task failed', record.stageId));
|
|
continue;
|
|
}
|
|
const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id;
|
|
const blob = await resolveMediaBytes?.(record);
|
|
if (!blob) {
|
|
missingResources.push(missing('media', elementId, 'no media bytes', record.stageId));
|
|
continue;
|
|
}
|
|
const ext = record.mimeType?.split('/')[1] || 'jpg';
|
|
const path = `media/${elementId}.${ext}`;
|
|
const bytes = await blobToBytes(blob);
|
|
let posterPath: string | undefined;
|
|
let posterBytes: Uint8Array | undefined;
|
|
if (record.poster) {
|
|
posterPath = path.replace(/\.\w+$/, '.poster.jpg');
|
|
posterBytes = await blobToBytes(record.poster);
|
|
}
|
|
collectedMedia.push({
|
|
path,
|
|
bytes,
|
|
sha: await sha256Hex(bytes),
|
|
ext,
|
|
elementId,
|
|
posterPath,
|
|
posterBytes,
|
|
record,
|
|
});
|
|
zip.file(path, bytes);
|
|
if (posterPath && posterBytes) zip.file(posterPath, posterBytes);
|
|
}
|
|
|
|
// ── 3. Inline interactive HTML ─────────────────────────────────────────
|
|
const inlineReport: InlineReport = { inlined: [], failed: [] };
|
|
const preparedScenes: Scene[] = (await Promise.all(
|
|
scenes.map(async (scene) => {
|
|
const content = scene.content;
|
|
if (
|
|
content?.type !== 'interactive' ||
|
|
!('html' in content) ||
|
|
typeof content.html !== 'string' ||
|
|
!content.html
|
|
) {
|
|
return scene;
|
|
}
|
|
const { html, report, unresolved } = await inlineHtmlAssets(content.html, {
|
|
...(fetchImpl ? { fetchImpl } : {}),
|
|
...(strictInteractiveAssets ? { keepImportmapFallbacks: false } : {}),
|
|
});
|
|
inlineReport.inlined.push(...report.inlined);
|
|
inlineReport.failed.push(...report.failed);
|
|
if (strictInteractiveAssets) {
|
|
for (const url of unresolved) {
|
|
missingResources.push(
|
|
missing('interactive', url, 'interactive asset remained external', scene.id),
|
|
);
|
|
}
|
|
for (const dependency of residualExecutableNetworkDependencies(html)) {
|
|
missingResources.push(
|
|
missing(
|
|
'interactive',
|
|
dependency,
|
|
dependency === 'dynamic:loadPyodide'
|
|
? 'Pyodide runtime is not frozen for offline playback'
|
|
: 'inline script retains an external runtime URL',
|
|
scene.id,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return { ...scene, content: { ...content, html } } as Scene;
|
|
}),
|
|
)).map((scene) => {
|
|
if (requireNarrationAudio || !scene.actions?.length) return scene;
|
|
const actions = scene.actions.map((action) => {
|
|
if (action.type !== 'speech' || (action.audioId && audioIdToPath.has(action.audioId))) {
|
|
return action;
|
|
}
|
|
// A deliberately silent bundle must not retain a remote audio URL. The
|
|
// player will use its narration-duration fallback and never reach the
|
|
// network for a resource the operator explicitly chose not to generate.
|
|
const { audioUrl: _audioUrl, ...silentSpeech } = action;
|
|
return silentSpeech;
|
|
});
|
|
return { ...scene, actions } as Scene;
|
|
});
|
|
for (const failure of inlineReport.failed) {
|
|
missingResources.push(
|
|
missing('interactive', failure.url, 'interactive asset could not be inlined'),
|
|
);
|
|
}
|
|
if (requireInteractiveHtml) {
|
|
const interactiveHtmlScenes = preparedScenes.filter(
|
|
(scene) =>
|
|
scene.content?.type === 'interactive' &&
|
|
typeof scene.content.html === 'string' &&
|
|
scene.content.html.trim().length > 0,
|
|
);
|
|
if (interactiveHtmlScenes.length === 0) {
|
|
missingResources.push(
|
|
missing('interactive', coursewareId, 'no interactive scene with generated HTML'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 4. Media index (mirrors the interactive-ZIP export) ────────────────
|
|
const mediaIndex: Record<string, import('@/lib/export/classroom-zip-types').MediaIndexEntry> = {};
|
|
for (const audio of collectedAudio) {
|
|
mediaIndex[audio.path] = { type: 'audio', format: audio.ext };
|
|
}
|
|
for (const media of collectedMedia) {
|
|
mediaIndex[media.path] = {
|
|
type: 'generated',
|
|
mimeType: media.record.mimeType,
|
|
size: media.record.size,
|
|
prompt: media.record.prompt,
|
|
};
|
|
}
|
|
if (requireNarrationAudio) {
|
|
// Strict narration packages expose the gap to the player and to the
|
|
// completeness gate. TTS-disabled packages intentionally omit the ref.
|
|
for (const scene of scenes) {
|
|
for (const action of scene.actions ?? []) {
|
|
if (action.type === 'speech') {
|
|
const audioId = (action as SpeechAction).audioId;
|
|
if (audioId && !audioIdToPath.has(audioId)) {
|
|
mediaIndex[`audio/${audioId}.mp3`] = { type: 'audio', missing: true };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 5. Manifest ────────────────────────────────────────────────────────
|
|
const manifest = buildManifest(
|
|
stage,
|
|
preparedScenes,
|
|
{
|
|
appVersion,
|
|
audioIdToPath,
|
|
agentConfigs,
|
|
exportedAt: publishedAt,
|
|
},
|
|
mediaIndex,
|
|
);
|
|
|
|
// ── 6. Quiz + knowledge packs ──────────────────────────────────────────
|
|
const quiz = extractQuizPack(preparedScenes);
|
|
const knowledge: KnowledgePack = extractKnowledgePack(coursewareId, stage, preparedScenes);
|
|
|
|
// ── 7. Completeness ────────────────────────────────────────────────────
|
|
const completeness: CompletenessReport = {
|
|
complete: missingResources.length === 0,
|
|
missing: missingResources,
|
|
audioCount: collectedAudio.length,
|
|
mediaCount: collectedMedia.length,
|
|
interactiveScenes: preparedScenes.filter((s) => s.content?.type === 'interactive').length,
|
|
};
|
|
if (requireComplete && !completeness.complete) {
|
|
const detail = completeness.missing.map((m) => `${m.kind}:${m.ref} (${m.reason})`).join('; ');
|
|
throw new Error(`Frozen bundle incomplete (${coursewareId} v${version}): ${detail}`);
|
|
}
|
|
|
|
// ── 8. Deterministic content hash over the payload ─────────────────────
|
|
// Every file except bundle.json (which carries the hash) contributes its
|
|
// byte-sha256 under its ZIP path; paths are sorted before hashing so the
|
|
// hash is independent of ZIP internals and verifiable after upload by
|
|
// {@link computeBundleContentHash}.
|
|
const manifestJson = JSON.stringify(manifest, null, 2);
|
|
const quizJson = JSON.stringify(quiz, null, 2);
|
|
const knowledgeJson = JSON.stringify(knowledge, null, 2);
|
|
const hashParts: Array<[string, string]> = [
|
|
[
|
|
'manifest.json',
|
|
await hashPayloadFile(
|
|
'manifest.json',
|
|
encoder.encode(manifestJson),
|
|
FROZEN_BUNDLE_CONTENT_HASH_VERSION,
|
|
),
|
|
],
|
|
[FROZEN_BUNDLE_QUIZ_FILE, await sha256Hex(new TextEncoder().encode(quizJson))],
|
|
[FROZEN_BUNDLE_KNOWLEDGE_FILE, await sha256Hex(new TextEncoder().encode(knowledgeJson))],
|
|
];
|
|
for (const audio of collectedAudio) hashParts.push([audio.path, audio.sha]);
|
|
for (const media of collectedMedia) {
|
|
hashParts.push([media.path, media.sha]);
|
|
if (media.posterPath && media.posterBytes) {
|
|
hashParts.push([media.posterPath, await sha256Hex(media.posterBytes)]);
|
|
}
|
|
}
|
|
hashParts.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
const contentHash = await sha256Hex(new TextEncoder().encode(JSON.stringify(hashParts)));
|
|
|
|
// ── 9. Bundle metadata + files ─────────────────────────────────────────
|
|
const meta: FrozenBundleMeta = {
|
|
kind: FROZEN_BUNDLE_KIND,
|
|
formatVersion: FROZEN_BUNDLE_FORMAT_VERSION,
|
|
coursewareId,
|
|
version,
|
|
publishedAt,
|
|
appVersion,
|
|
stageName: stage.name,
|
|
...(stage.languageDirective ? { language: stage.languageDirective } : {}),
|
|
sceneCount: preparedScenes.length,
|
|
contentHash,
|
|
contentHashVersion: FROZEN_BUNDLE_CONTENT_HASH_VERSION,
|
|
knowledgeVersion: 1,
|
|
runtimeCapabilities: {
|
|
offlinePlayback: true,
|
|
htmlSandbox: { externalNetwork: false, hostBridgeOnly: true },
|
|
packaged: {
|
|
audio: collectedAudio.length > 0,
|
|
media: collectedMedia.length > 0,
|
|
interactiveHtml: preparedScenes.some((scene) => scene.content?.type === 'interactive'),
|
|
pblTemplate: preparedScenes.some((scene) => scene.content?.type === 'pbl'),
|
|
objectiveQuizGrading: preparedScenes.some((scene) => scene.content?.type === 'quiz'),
|
|
},
|
|
hostBridges: {
|
|
agent: 'online-optional',
|
|
asr: 'online-optional',
|
|
pblEvaluation: 'online-optional',
|
|
subjectiveQuizGrading: 'online-optional',
|
|
},
|
|
},
|
|
};
|
|
|
|
zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify({ meta, completeness }, null, 2));
|
|
zip.file('manifest.json', manifestJson);
|
|
zip.file(FROZEN_BUNDLE_QUIZ_FILE, quizJson);
|
|
zip.file(FROZEN_BUNDLE_KNOWLEDGE_FILE, knowledgeJson);
|
|
|
|
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
|
const zipBlob = new Blob([bytes as unknown as BlobPart], { type: 'application/zip' });
|
|
const entryCount = Object.values(zip.files).filter((file) => !file.dir).length;
|
|
|
|
return {
|
|
zip: zipBlob,
|
|
contentHash,
|
|
entryCount,
|
|
manifest,
|
|
meta,
|
|
completeness,
|
|
quiz,
|
|
knowledge,
|
|
inlineReport,
|
|
};
|
|
}
|
|
|
|
/** Normalize a ZIP source for jszip (Node's Blob is not directly consumable). */
|
|
async function toZipInput(
|
|
zipBlob: Blob | Uint8Array | ArrayBuffer,
|
|
): Promise<ArrayBuffer | Uint8Array> {
|
|
if (typeof Blob !== 'undefined' && zipBlob instanceof Blob) return zipBlob.arrayBuffer();
|
|
// The guard above can't statically exclude Blob (Blob may be undefined at
|
|
// runtime, defeating the instanceof narrowing); callers never pass a Blob
|
|
// here when the global is absent.
|
|
return zipBlob as Uint8Array | ArrayBuffer;
|
|
}
|
|
|
|
/**
|
|
* Recompute the deterministic payload hash from a ZIP's files (everything
|
|
* except `bundle.json`). Must agree with `packageCourseware().contentHash`;
|
|
* the publish route uses this to reject corrupted or tampered uploads.
|
|
*/
|
|
export async function computeBundleContentHash(
|
|
zipBlob: Blob | Uint8Array | ArrayBuffer,
|
|
): Promise<string> {
|
|
const JSZip = (await import('jszip')).default;
|
|
const zip = await JSZip.loadAsync(await toZipInput(zipBlob));
|
|
const bundleFile = zip.file(FROZEN_BUNDLE_MANIFEST_FILE);
|
|
if (!bundleFile) {
|
|
throw new Error(`Frozen bundle is missing ${FROZEN_BUNDLE_MANIFEST_FILE}`);
|
|
}
|
|
const bundleDocument = JSON.parse(await bundleFile.async('string')) as {
|
|
meta?: Pick<FrozenBundleMeta, 'contentHashVersion'>;
|
|
};
|
|
const hashVersion = contentHashVersion(bundleDocument.meta?.contentHashVersion);
|
|
const hashParts: Array<[string, string]> = [];
|
|
for (const [name, file] of Object.entries(zip.files)) {
|
|
if (file.dir || name === FROZEN_BUNDLE_MANIFEST_FILE) continue;
|
|
const bytes = new Uint8Array(await file.async('uint8array'));
|
|
hashParts.push([name, await hashPayloadFile(name, bytes, hashVersion)]);
|
|
}
|
|
hashParts.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
return sha256Hex(new TextEncoder().encode(JSON.stringify(hashParts)));
|
|
}
|
|
|
|
/**
|
|
* Rewrite only the registry-owned publish version in `bundle.json`.
|
|
*
|
|
* Browser packaging cannot safely predict the next monotonic registry
|
|
* version. The publish service calls this after allocating the version under
|
|
* its per-courseware lock. `bundle.json` is deliberately excluded from the
|
|
* deterministic payload hash, so this operation preserves `contentHash` while
|
|
* making the stored ZIP's immutable identity agree with the registry record.
|
|
*/
|
|
export async function rewriteFrozenBundleVersion(
|
|
zipBlob: Blob | Uint8Array | ArrayBuffer,
|
|
version: number,
|
|
): Promise<Uint8Array> {
|
|
if (!Number.isInteger(version) || version < 1) {
|
|
throw new Error(`Invalid frozen bundle version: ${version}`);
|
|
}
|
|
|
|
const JSZip = (await import('jszip')).default;
|
|
const zip = await JSZip.loadAsync(await toZipInput(zipBlob));
|
|
const manifestFile = zip.file(FROZEN_BUNDLE_MANIFEST_FILE);
|
|
if (!manifestFile) {
|
|
throw new Error(`Frozen bundle is missing ${FROZEN_BUNDLE_MANIFEST_FILE}`);
|
|
}
|
|
|
|
const document = JSON.parse(await manifestFile.async('string')) as {
|
|
meta?: FrozenBundleMeta;
|
|
completeness?: CompletenessReport;
|
|
};
|
|
if (!document.meta || !document.completeness) {
|
|
throw new Error(`Frozen bundle has invalid ${FROZEN_BUNDLE_MANIFEST_FILE}`);
|
|
}
|
|
document.meta = { ...document.meta, version };
|
|
zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify(document, null, 2));
|
|
|
|
return zip.generateAsync({ type: 'uint8array' });
|
|
}
|
|
|
|
/** Unzip a frozen bundle's document files (manifest/bundle/quiz/knowledge) without extracting media. */
|
|
export async function readFrozenBundleDocuments(zipBlob: Blob | Uint8Array | ArrayBuffer): Promise<{
|
|
manifest: ReturnType<typeof buildManifest>;
|
|
meta: FrozenBundleMeta;
|
|
completeness: CompletenessReport;
|
|
quiz: QuizPack;
|
|
knowledge: KnowledgePack;
|
|
entryCount: number;
|
|
}> {
|
|
const JSZip = (await import('jszip')).default;
|
|
const data = await toZipInput(zipBlob);
|
|
const zip = await JSZip.loadAsync(data);
|
|
const parse = async <T>(name: string): Promise<T> =>
|
|
JSON.parse(await zip.file(name)!.async('string')) as T;
|
|
const [manifest, bundleDocument, quiz, knowledge] = await Promise.all([
|
|
parse<ReturnType<typeof buildManifest>>('manifest.json'),
|
|
parse<{ meta: FrozenBundleMeta; completeness: CompletenessReport }>(
|
|
FROZEN_BUNDLE_MANIFEST_FILE,
|
|
),
|
|
parse<QuizPack>(FROZEN_BUNDLE_QUIZ_FILE),
|
|
parse<KnowledgePack>(FROZEN_BUNDLE_KNOWLEDGE_FILE),
|
|
]);
|
|
return {
|
|
manifest,
|
|
meta: bundleDocument.meta,
|
|
completeness: bundleDocument.completeness,
|
|
quiz,
|
|
knowledge,
|
|
entryCount: Object.values(zip.files).filter((file) => !file.dir).length,
|
|
};
|
|
}
|