presenton/servers/nextjs/lib/run-bundled-presentation-export.ts
sudipnext 5766e252aa feat: Implement PDF export functionality with session handling
- Added middleware to handle session cookies for the PDF export route.
- Introduced a new API endpoint for exporting presentation data using session cookies.
- Updated the PdfMakerPage component to accept and utilize the export cookie.
- Enhanced the presentation export logic to include session token extraction from cookies.
- Updated routing configuration to include the new PDF maker path.
2026-04-27 21:13:57 +05:45

215 lines
6.5 KiB
TypeScript

import path from "path";
import os from "os";
import fs from "fs/promises";
import { spawn } from "child_process";
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
/** Repo `presentation-export/` at app root (`/app/presentation-export` in Docker). */
export function getExportPackageRoot(): string {
return (
process.env.EXPORT_PACKAGE_ROOT?.trim() ||
path.join(process.cwd(), "..", "..", "presentation-export")
);
}
export function getPresentonAppRoot(): string {
return (
process.env.PRESENTON_APP_ROOT?.trim() ||
path.join(process.cwd(), "..", "..")
);
}
function extractSessionTokenFromCookieHeader(cookieHeader?: string): string | undefined {
if (!cookieHeader) {
return undefined;
}
const match = cookieHeader.match(/(?:^|;\s*)presenton_session=([^;]+)/);
if (!match?.[1]) {
return undefined;
}
return decodeURIComponent(match[1]);
}
async function resolveExportEntrypoint(exportRoot: string): Promise<string> {
const indexCjs = path.join(exportRoot, "index.cjs");
const indexJs = path.join(exportRoot, "index.js");
try {
await fs.access(indexCjs);
return indexCjs;
} catch {
await fs.access(indexJs);
await fs.copyFile(indexJs, indexCjs);
return indexCjs;
}
}
function bundledConverterPath(exportRoot: string): string {
const fromEnv = process.env.BUILT_PYTHON_MODULE_PATH?.trim();
if (fromEnv) {
return fromEnv;
}
if (process.platform === "linux" && process.arch === "x64") {
return path.join(exportRoot, "py", "convert-linux-x64");
}
throw new Error(
`No bundled export converter for ${process.platform}/${process.arch}. Set BUILT_PYTHON_MODULE_PATH.`
);
}
export async function bundledExportPackageAvailable(): Promise<boolean> {
try {
const root = getExportPackageRoot();
await resolveExportEntrypoint(root);
await fs.access(bundledConverterPath(root));
return true;
} catch {
return false;
}
}
export type BundledPresentationExportFormat = "pdf" | "pptx";
export type BundledPresentationExportResult = { path: string };
function normalizeExportOutputPath(params: {
pathValue?: string;
urlValue?: string;
}): string {
const { pathValue, urlValue } = params;
const appData = process.env.APP_DATA_DIRECTORY?.trim();
const resolveAppDataRelative = (value: string): string => {
if (!appData) {
throw new Error("APP_DATA_DIRECTORY is required for relative export paths.");
}
const normalized = value.startsWith("/") ? value.slice(1) : value;
if (!normalized.startsWith("app_data/")) {
return path.join(appData, normalized);
}
return path.join(appData, normalized.slice("app_data/".length));
};
if (pathValue && typeof pathValue === "string") {
if (path.isAbsolute(pathValue)) {
return pathValue;
}
return resolveAppDataRelative(pathValue);
}
if (urlValue && typeof urlValue === "string") {
if (urlValue.startsWith("file://")) {
const parsed = new URL(urlValue);
const fsPath = decodeURIComponent(parsed.pathname || "");
if (fsPath.startsWith("/app_data/")) {
return resolveAppDataRelative(fsPath);
}
if (path.isAbsolute(fsPath)) {
return fsPath;
}
return resolveAppDataRelative(fsPath);
}
if (urlValue.startsWith("/app_data/")) {
return resolveAppDataRelative(urlValue);
}
}
throw new Error("Export finished but response did not include a valid output path.");
}
/**
* Runs the bundled export entrypoint (`presentation-export/index.js`) with
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
*/
export async function runBundledPresentationExport(params: {
presentationId: string;
title: string | undefined;
format: BundledPresentationExportFormat;
cookieHeader?: string;
}): Promise<BundledPresentationExportResult> {
const { presentationId, title, format, cookieHeader } = params;
const exportRoot = getExportPackageRoot();
const entrypoint = await resolveExportEntrypoint(exportRoot);
const converter = bundledConverterPath(exportRoot);
const appRoot = getPresentonAppRoot();
await fs.access(converter);
const nextjsUrl =
process.env.NEXT_PUBLIC_URL?.trim() || "http://127.0.0.1";
const q = new URLSearchParams({ id: presentationId });
const sessionToken = extractSessionTokenFromCookieHeader(cookieHeader);
if (sessionToken) {
q.set("exportSession", sessionToken);
}
const fastapiUrl = process.env.NEXT_PUBLIC_FAST_API?.trim();
if (fastapiUrl) {
q.set("fastapiUrl", fastapiUrl);
}
const basePptUrl = `${nextjsUrl}/pdf-maker?${q.toString()}`;
const pptUrl = cookieHeader?.trim()
? `${basePptUrl}#exportCookie=${encodeURIComponent(cookieHeader)}`
: basePptUrl;
const tempBase =
process.env.TEMP_DIRECTORY?.trim() || path.join(os.tmpdir(), "presenton");
await fs.mkdir(tempBase, { recursive: true });
const workDir = await fs.mkdtemp(path.join(tempBase, "export-"));
const exportTaskPath = path.join(workDir, "export_task.json");
const exportTask = {
type: "export",
url: pptUrl,
format,
title: sanitizeFilename(title ?? "presentation"),
fastapiUrl: fastapiUrl || undefined,
cookieHeader: cookieHeader || undefined,
};
await fs.writeFile(exportTaskPath, JSON.stringify(exportTask), "utf8");
const responsePath = exportTaskPath.replace(/\.json$/i, ".response.json");
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, [entrypoint, exportTaskPath], {
cwd: appRoot,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
BUILT_PYTHON_MODULE_PATH: converter,
},
});
const stderr: Buffer[] = [];
const stdout: Buffer[] = [];
child.stderr?.on("data", (d) => stderr.push(d));
child.stdout?.on("data", (d) => stdout.push(d));
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
const errText = Buffer.concat(stderr).toString("utf8").trim();
const outText = Buffer.concat(stdout).toString("utf8").trim();
reject(
new Error(
`Export process exited with code ${code}${errText ? `. ${errText}` : ""}${outText ? ` stdout: ${outText}` : ""}`
)
);
}
});
});
const responseRaw = await fs.readFile(responsePath, "utf8");
const responseData = JSON.parse(responseRaw) as { path?: string; url?: string };
const outPath = normalizeExportOutputPath({
pathValue: responseData?.path,
urlValue: responseData?.url,
});
return { path: outPath };
}