Markdown to PDF in tauri app
I'm using this approach in my note application to print markdown document using tauri with a NodeJS sidecar.
Steps
- Create a sidecar
- Build the sidecar
- Setup everything
I will only cover the sidecar part.
Step One - Create the Sidecar
From the root of the project,
mkdir -p server/{scripts,src}
cd server
npm init -y
Create the script to build the binary:
scripts/build-server.mjs
import execa from "execa";
import fs from "node:fs";
import { oraPromise } from "ora";
import * as esbuild from "esbuild";
import alias from "esbuild-plugin-alias";
import path from "node:path";
/**
* This function is used to rename the binary with the platform specific postfix.
* When `tauri build` is ran, it looks for the binary name appended with the platform specific postfix.
*/
async function moveBinaries() {
let extension = "";
if (process.platform === "win32") {
extension = ".exe";
}
const rustInfo = (await execa("rustc", ["-vV"])).stdout;
const targetTriple = /host: (\S+)/g.exec(rustInfo)[1];
if (!targetTriple) {
console.error("Failed to determine platform target triple");
}
fs.renameSync(
`../src-tauri/binaries/app${extension}`,
`../src-tauri/binaries/app-${targetTriple}${extension}`
);
}
/**
* This function is used to create bundle for server. `Pkg` is not supporting es module resolution
* that we need to create typesafety within tRPC
*/
async function createBundle() {
await esbuild.build({
entryPoints: ["./src/index.ts"],
bundle: true,
outfile: "./dist/server.js",
platform: "node",
external: ["highlight.js"],
plugins: [
alias({
"yargs/yargs": path.resolve(
`node_modules/@puppeteer/browsers/node_modules/yargs/yargs.mjs`
),
}),
],
});
fs.copyFileSync("node_modules/vm2/lib/bridge.js", "dist/bridge.js");
fs.copyFileSync(
"node_modules/vm2/lib/setup-sandbox.js",
"dist/setup-sandbox.js"
);
fs.copyFileSync("node_modules/md-to-pdf/markdown.css", "dist/markdown.css");
}
/**
* This function is used to create single executable from server file and nodejs
*/
async function createServerPackage() {
return execa("node_modules/.bin/pkg", [
"package.json",
"--public",
"--debug",
"--output",
"../src-tauri/binaries/app",
]);
}
async function main() {
try {
await createBundle();
await createServerPackage();
await moveBinaries();
} catch (e) {
throw e;
}
}
oraPromise(main, {
text: "Building server...\n",
successText: "Done\n",
failText: "Cannot build server",
});
This script contains everything needed to build and package the requirements in order to get a working sidecar.
Create the Server to interact with puppeteer:
src/index.ts
import cors from "cors";
import express, { Application, Request, Response } from "express";
import { mdToPdf } from "md-to-pdf";
import path from "path";
import bodyParser from "body-parser";
const app: Application = express();
const PORT: number = 3000;
app.use(cors());
app.use(bodyParser.json({ limit: "30mb" }));
app.use("/", async (req: Request, res: Response): Promise<void> => {
try {
const pdf = await mdToPdf(
{ content: req.body.content },
{
launch_options: { headless: "new" },
port: 3333,
stylesheet: [path.join(__dirname, "markdown.css")],
}
);
const buffer = pdf.content.buffer;
res.status(200).json({
message: buffer.byteLength,
payload: Buffer.from(buffer).toString("base64"),
});
} catch (e) {
res.status(500).json({ message: e.message });
}
});
app.listen(PORT, (): void => {
console.log("SERVER IS UP ON PORT:", PORT);
});
This server listens on port 3333 and receive a Markdown document has input. Then using md-to-pdf
it generates a PDF Document and send it backs to the frontend using base64 format.
The package.json:
Notice the specific versions.
{
"name": "sidecar",
"private": true,
"version": "1.0.0",
"author": "Studio Webux",
"description": "Sidecar to process markdown to PDF Easily",
"type": "module",
"bin": "./dist/server.js",
"scripts": {
"server:build": "node ./scripts/build-server.mjs"
},
"devDependencies": {
"esbuild-plugin-alias": "^0.2.1",
"esbuild": "0.14.54",
"execa": "5.1.1",
"ora": "6.1.2",
"pkg": "5.8.0"
},
"dependencies": {
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"md-to-pdf": "^5.2.4"
},
"pkg": {
"assets": [
"dist/bridge.js",
"dist/setup-sandbox.js",
"dist/markdown.css",
"node_modules/highlight.js/**"
],
"outputPath": "dist"
}
}
This is the only solution I was able to get a working environment with. (I didn't test Windows, because it simply does not work at all, the issue I have is the shortcuts don't work, there is a github issue opened about it for a while now.)
Step 2 - Build the Sidecar
npm run server:build
This command will build and generate a NodeJS binary containing everything needed to run the server.
The binary file is saved inside src-tauri/binaries/app-MACHINE_ARCH
.
The suffix of the file name is quite important. You will have to build that binary for all distribution.
I'm using Github actions to achieve a working environment and a matrice to build for all OSes. You can use the official tauri documentation to make it work. I'll review my setup and probably post another article with the pipeline.
===
Step 3 - Setup Everything
To use this binary in tauri.
- Edit the
src-tauri/tauri.conf.json
{
// ...
"tauri": {
"allowlist": {
"shell": {
"all": false,
"open": true,
"sidecar": true,
"scope": [
{
"name": "binaries/app",
"sidecar": true
}
]
}
},
"bundle": {
// ...
"externalBin": ["binaries/app"]
// ...
}
}
// ...
}
- To use the server in the frontend
Snippet of the code I use to achieve this:
import { writeBinaryFile } from "@tauri-apps/api/fs";
import { TauriEvent, listen } from "@tauri-apps/api/event";
import { documentDir, join, sep } from "@tauri-apps/api/path";
import { Child, Command } from "@tauri-apps/api/shell";
import { Body, fetch as tauriFetch } from "@tauri-apps/api/http";
export async function saveBinaryFile(
filepath = "",
content: ArrayBuffer
): Promise<void> {
const path = await normalize(filepath);
return writeBinaryFile(path, content, { dir: BaseDirectory.Document });
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
if (!base64) throw new Error("Received an empty base64");
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
// ----
const event = new CustomEvent("export", { detail: { status: "Starting" } });
document.dispatchEvent(event);
let child: Child | undefined = undefined;
try {
/**
* Running NodeJS process as a sidecar
*/
const cmd = Command.sidecar("binaries/app");
child = await cmd.spawn();
listen(TauriEvent.WINDOW_DESTROYED, function () {
child?.kill();
});
cmd.on("error", (err) => {
child?.kill();
console.error(err);
return resolve(err);
});
cmd.stderr.on("data", (data) => {
child?.kill();
return resolve(data);
});
// Waiting for the server to be ready
cmd.stdout.on("data", async (d) => {
if (d === "SERVER IS UP ON PORT: 3000") {
try {
event.detail.status = "Preparing Images";
document.dispatchEvent(event);
// Your functions to prepare the markdown `content`
let content = "# Hello World";
let res: { data: { message: string; payload: string } };
// Call the express endpoint
res = await tauriFetch("http://localhost:3000", {
method: "POST",
body: Body.json({ content }),
});
const filepathToSavePDF = await join(
"path_to_save_the_pdf",
`${filename}.pdf`
);
await saveBinaryFile(
filepathToSavePDF,
base64ToArrayBuffer(res.data.payload as string)
);
event.detail.status = "File Saved";
document.dispatchEvent(event);
await child?.kill();
return resolve(true);
} catch (e: any) {
await child?.kill();
return resolve(e.message);
}
}
});
} catch (e: any) {
console.error("ERROR", e);
event.detail.status = e.message;
document.dispatchEvent(event);
}
these snippets are used to notify the client about was is going on.
event.detail.status = "File Saved";
document.dispatchEvent(event);
The code waits to get the server up and running, then get the content, send it to the backend and save the base64 content in a local file.
You might have issues when saving, be sure to use a valid directory and that tauri allows everything.
Something like that is required to access the Documents
directory:
{
// ...
"tauri": {
"allowlist": {
"fs": {
"readFile": true,
"writeFile": true,
"readDir": true,
"createDir": true,
"exists": true,
"scope": ["$DOCUMENT/**", "$DOCUMENT/*", "$DOCUMENT"]
}
}
}
// ...
}
That's pretty much it !
Hopes it gives some guidances about how to use NodeJS Sidecars with tauri. I did similar thing using puppeteer and AWS SDK. The idea is always the same.