import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { createLiveSessionStore } from './session-store.mjs'; import { withSourceLockSync } from './source-lock.mjs'; import { getLiveDir } from '../lib/impeccable-paths.mjs'; export function sha256(value) { return createHash('sha256').update(value).digest('hex'); } export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd() } = {}) { if (!id) return failure('missing_session_id'); if (!sourceFile) return failure('missing_file'); const requestedPath = resolveInside(cwd, sourceFile); if (!requestedPath || !fs.existsSync(requestedPath)) return failure(requestedPath ? 'source_missing' : 'path_outside_project'); const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id); if (componentTarget?.error) return componentTarget; const sourcePath = componentTarget?.sourcePath || requestedPath; try { return withSourceLockSync(sourcePath, 'generation-prepare:' + id, () => { const store = createLiveSessionStore({ cwd, sessionId: id }); const snapshot = store.getSnapshot(id, { includeCompleted: true }); if (!snapshot?.updatedAt) return failure('session_missing'); if (snapshot.generationCanceled === true) { return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase }); } const source = fs.readFileSync(sourcePath, 'utf-8'); const revision = Number(snapshot.publishedRevision || 0) + 1; const artifactDir = path.join(getLiveDir(cwd), 'artifacts'); if (componentTarget) { return prepareComponentArtifact({ id, revision, snapshot, source, sourcePath, requestedPath, target: componentTarget, artifactDir, cwd, }); } const extension = path.extname(sourcePath) || '.html'; const artifactPath = path.join(artifactDir, id + '-r' + revision + extension); fs.mkdirSync(artifactDir, { recursive: true }); fs.writeFileSync(artifactPath, source, 'utf-8'); return { ok: true, id, epoch: Number(snapshot.generationEpoch || 1), revision, sourceFile: relative(cwd, sourcePath), artifactFile: relative(cwd, artifactPath), expectedSourceHash: sha256(source), }; }, { cwd }); } catch (error) { if (error?.code === 'SOURCE_LOCKED') return failure('source_locked'); return failure('prepare_failed', { message: error?.message || String(error) }); } } export function publishGenerationArtifact({ id, epoch, sourceFile, artifactFile, expectedSourceHash, arrivedVariants, expectedVariants, cwd = process.cwd(), } = {}) { if (!id) return failure('missing_session_id'); if (!Number.isInteger(epoch) || epoch < 1) return failure('invalid_generation_epoch'); if (!sourceFile || !artifactFile) return failure('missing_file'); const requestedPath = resolveInside(cwd, sourceFile); const artifactPath = resolveInside(cwd, artifactFile); if (!requestedPath || !artifactPath) return failure('path_outside_project'); if (!fs.existsSync(requestedPath)) return failure('source_missing'); if (!fs.existsSync(artifactPath)) return failure('artifact_missing'); const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id); if (componentTarget?.error) return componentTarget; const artifactManifest = readJson(artifactPath); const isComponentArtifact = isComponentPreviewMode(artifactManifest?.previewMode); if (Boolean(componentTarget) !== isComponentArtifact) { return failure('artifact_preview_mode_mismatch'); } if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) { return failure('artifact_preview_mode_mismatch'); } const sourcePath = componentTarget?.sourcePath || requestedPath; try { return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => { const store = createLiveSessionStore({ cwd, sessionId: id }); const snapshot = store.getSnapshot(id, { includeCompleted: true }); if (!snapshot?.updatedAt) return failure('session_missing'); if (snapshot.generationCanceled === true) { return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase }); } if (Number(snapshot.generationEpoch || 1) !== epoch) { return failure('stale_generation_epoch', { expectedEpoch: snapshot.generationEpoch || 1 }); } const current = fs.readFileSync(sourcePath, 'utf-8'); const currentHash = sha256(current); if (!expectedSourceHash || currentHash !== expectedSourceHash) { return failure('source_hash_mismatch', { actualSourceHash: currentHash }); } if (componentTarget) { return publishComponentArtifact({ id, epoch, snapshot, target: componentTarget, artifactManifest, artifactPath, sourcePath, arrivedVariants, expectedVariants, store, cwd, }); } const artifact = fs.readFileSync(artifactPath, 'utf-8'); if (!artifact.includes('data-impeccable-variants="' + id + '"')) { return failure('artifact_missing_session_wrapper'); } const delivered = countDeliveredVariants(artifact); if (delivered < 1) return failure('artifact_has_no_variants'); if (Number.isInteger(arrivedVariants) && delivered < arrivedVariants) { return failure('artifact_variant_count_mismatch', { delivered }); } const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0)); for (let variant = 1; variant <= priorArrived; variant++) { const currentVariant = extractVariantBlock(current, variant); const artifactVariant = extractVariantBlock(artifact, variant); if (!currentVariant || !artifactVariant) { return failure('published_variant_missing', { variant }); } if (sha256(currentVariant) !== sha256(artifactVariant)) { return failure('published_variant_changed', { variant }); } } const currentPreviewCss = extractPreviewCss(current, id); const artifactPreviewCss = extractPreviewCss(artifact, id); if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) { return failure('published_variant_css_changed'); } const commitSnapshot = store.getSnapshot(id, { includeCompleted: true }); if (commitSnapshot?.generationCanceled === true) { return failure('stale_generation_epoch', { canceled: true, phase: commitSnapshot.phase }); } if (Number(commitSnapshot?.generationEpoch || 1) !== epoch) { return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 }); } const artifactHash = sha256(artifact); atomicReplace(sourcePath, artifact); const revision = Number(commitSnapshot.publishedRevision || 0) + 1; store.appendEvent({ type: 'variant_published', id, generationEpoch: epoch, revision, digest: artifactHash, sourceFile: relative(cwd, sourcePath), arrivedVariants: delivered, expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered), at: Date.now(), }); return { ok: true, id, epoch, revision, digest: artifactHash, sourceFile: relative(cwd, sourcePath), arrivedVariants: delivered, expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered), }; }, { cwd }); } catch (error) { if (error?.code === 'SOURCE_LOCKED') return failure('source_locked'); return failure('publish_failed', { message: error?.message || String(error) }); } } function prepareComponentArtifact({ id, revision, snapshot, source, sourcePath, requestedPath, target, artifactDir, cwd, }) { const artifactComponentDir = path.join( artifactDir, id + '-r' + revision + '-' + target.manifest.previewMode + '-' + process.pid + '-' + Date.now(), ); fs.mkdirSync(artifactComponentDir, { recursive: true }); copyDirectoryFiles(target.componentPath, artifactComponentDir); const artifactPath = path.join(artifactComponentDir, 'manifest.json'); const artifactManifest = { ...target.manifest, componentDir: relative(cwd, artifactComponentDir), }; fs.writeFileSync(artifactPath, JSON.stringify(artifactManifest, null, 2) + '\n', 'utf-8'); return { ok: true, id, epoch: Number(snapshot.generationEpoch || 1), revision, sourceFile: relative(cwd, requestedPath), targetSourceFile: relative(cwd, sourcePath), artifactFile: relative(cwd, artifactPath), componentDir: relative(cwd, artifactComponentDir), previewMode: target.manifest.previewMode, expectedSourceHash: sha256(source), }; } function publishComponentArtifact({ id, epoch, snapshot, target, artifactManifest, artifactPath, sourcePath, arrivedVariants, expectedVariants, store, cwd, }) { if (!artifactManifest || typeof artifactManifest !== 'object') { return failure('artifact_manifest_invalid'); } if (artifactManifest.id !== id || target.manifest.id !== id) { return failure('artifact_session_mismatch'); } const artifactComponentPath = resolveInside(cwd, artifactManifest.componentDir); if (!artifactComponentPath || path.resolve(artifactComponentPath) !== path.dirname(artifactPath)) { return failure('artifact_component_dir_mismatch'); } if (!isDescendant(path.join(getLiveDir(cwd), 'artifacts'), artifactComponentPath)) { return failure('artifact_not_staged'); } const immutableMismatch = componentManifestMismatch(target.manifest, artifactManifest); if (immutableMismatch) { return failure('artifact_manifest_changed', { field: immutableMismatch }); } const expected = Number(expectedVariants || target.manifest.count || snapshot.expectedVariants || 0); const declared = optionalPositiveInteger(artifactManifest.arrivedVariants); const delivered = Number.isInteger(arrivedVariants) ? arrivedVariants : declared; if (!Number.isInteger(delivered) || delivered < 1) return failure('artifact_has_no_variants'); if (expected > 0 && delivered > expected) { return failure('artifact_variant_count_mismatch', { delivered, expected }); } if (declared !== null && declared !== delivered) { return failure('artifact_variant_count_mismatch', { delivered: declared, expected: delivered }); } const priorArrived = Math.max( optionalPositiveInteger(target.manifest.arrivedVariants) || 0, Number(snapshot.arrivedVariants || 0), ); if (delivered < priorArrived) { return failure('artifact_variant_count_regressed', { delivered, priorArrived }); } const componentExtension = target.manifest.componentExtension || (target.manifest.previewMode === 'vue-component' ? 'vue' : 'svelte'); const variantContents = []; for (let variant = 1; variant <= delivered; variant++) { const artifactVariantPath = path.join(artifactComponentPath, 'v' + variant + '.' + componentExtension); if (!regularFileInside(artifactComponentPath, artifactVariantPath)) { return failure('artifact_variant_missing', { variant }); } const content = fs.readFileSync(artifactVariantPath, 'utf-8'); if (!content.trim()) return failure('artifact_variant_empty', { variant }); const targetVariantPath = path.join(target.componentPath, 'v' + variant + '.' + componentExtension); if (variant <= priorArrived && !regularFileInside(target.componentPath, targetVariantPath)) { return failure('published_variant_missing', { variant }); } if (variant <= priorArrived) { const prior = fs.readFileSync(targetVariantPath, 'utf-8'); if (sha256(prior) !== sha256(content)) { return failure('published_variant_changed', { variant }); } } variantContents.push({ variant, content, targetPath: targetVariantPath }); } const artifactParamsPath = path.join(artifactComponentPath, 'params.json'); let paramsContent = null; if (fs.existsSync(artifactParamsPath)) { if (!regularFileInside(artifactComponentPath, artifactParamsPath)) { return failure('artifact_params_invalid'); } paramsContent = fs.readFileSync(artifactParamsPath, 'utf-8'); const params = parseJson(paramsContent); if (!params || typeof params !== 'object' || Array.isArray(params)) { return failure('artifact_params_invalid'); } } // Components and optional params become reachable before the manifest // advertises them. Committing the manifest last makes publication atomic // from the browser's point of view while the source lock excludes Accept. fs.mkdirSync(target.componentPath, { recursive: true }); for (const variant of variantContents) { if (variant.variant > priorArrived) atomicReplace(variant.targetPath, variant.content); } if (paramsContent !== null) { atomicReplace(path.join(target.componentPath, 'params.json'), paramsContent); } const commitSnapshot = store.getSnapshot(id, { includeCompleted: true }); if (commitSnapshot?.generationCanceled === true) { return failure('stale_generation_epoch', { canceled: true, phase: commitSnapshot.phase }); } if (Number(commitSnapshot?.generationEpoch || 1) !== epoch) { return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 }); } const publishedManifest = { ...target.manifest, componentDir: relative(cwd, target.componentPath), arrivedVariants: delivered, }; delete publishedManifest.manifestPath; const manifestContent = JSON.stringify(publishedManifest, null, 2) + '\n'; atomicReplace(target.manifestPath, manifestContent); const digest = digestComponentPublication(manifestContent, variantContents, paramsContent); const revision = Number(snapshot.publishedRevision || 0) + 1; const sourceFile = relative(cwd, sourcePath); const previewFile = relative(cwd, target.manifestPath); store.appendEvent({ type: 'variant_published', id, generationEpoch: epoch, revision, digest, sourceFile, previewFile, previewMode: target.manifest.previewMode, arrivedVariants: delivered, expectedVariants: expected || delivered, at: Date.now(), }); return { ok: true, id, epoch, revision, digest, sourceFile, previewFile, previewMode: target.manifest.previewMode, componentDir: relative(cwd, target.componentPath), arrivedVariants: delivered, expectedVariants: expected || delivered, }; } const COMPONENT_MANIFEST_FIELDS = [ 'id', 'mode', 'previewMode', 'sourceFile', 'sourceStartLine', 'sourceEndLine', 'insertLine', 'position', 'anchorStartLine', 'anchorEndLine', 'count', 'propContract', 'originalMarkup', 'anchorMarkup', 'runtimeModule', 'componentModuleBase', 'framework', 'componentExtension', ]; function readComponentPublicationTarget(manifestPath, cwd, id) { if (path.basename(manifestPath) !== 'manifest.json') return null; const manifest = readJson(manifestPath); if (!manifest || !isComponentPreviewMode(manifest.previewMode)) return null; if (manifest.id !== id) return failure('artifact_session_mismatch'); const sourcePath = resolveInside(cwd, manifest.sourceFile); const componentPath = resolveInside(cwd, manifest.componentDir); if (!sourcePath || !componentPath) return failure('path_outside_project'); if (!fs.existsSync(sourcePath)) return failure('source_missing'); if (path.resolve(componentPath) !== path.dirname(manifestPath)) { return failure('manifest_component_dir_mismatch'); } return { manifest, manifestPath, sourcePath, componentPath }; } function componentManifestMismatch(target, artifact) { for (const field of COMPONENT_MANIFEST_FIELDS) { if (JSON.stringify(target[field] ?? null) !== JSON.stringify(artifact[field] ?? null)) return field; } return null; } function isComponentPreviewMode(value) { return value === 'svelte-component' || value === 'vue-component'; } function copyDirectoryFiles(sourceDir, targetDir) { for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { if (!entry.isFile() || entry.isSymbolicLink()) continue; fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name)); } } function regularFileInside(root, file) { const rel = path.relative(root, file); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false; try { return fs.lstatSync(file).isFile(); } catch { return false; } } function isDescendant(root, candidate) { const rel = path.relative(root, candidate); return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel); } function digestComponentPublication(manifestContent, variants, paramsContent) { const hash = createHash('sha256'); hash.update(manifestContent); for (const variant of variants) { hash.update('\0v' + variant.variant + '\0'); hash.update(variant.content); } if (paramsContent !== null) hash.update('\0params\0' + paramsContent); return hash.digest('hex'); } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return null; } } function parseJson(value) { try { return JSON.parse(value); } catch { return null; } } function optionalPositiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; } function countDeliveredVariants(source) { const matches = source.match(/