business: DocFast deployed on Hetzner CAX11 (167.235.156.214)
This commit is contained in:
parent
37094c8945
commit
a1c86b0ebc
13 changed files with 181 additions and 111 deletions
|
|
@ -1,9 +1,17 @@
|
||||||
{
|
{
|
||||||
"budget": 200.00,
|
"budget": 200.00,
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"spent": 0.00,
|
"spent": 3.29,
|
||||||
"revenue": 0.00,
|
"revenue": 0.00,
|
||||||
"balance": 200.00,
|
"balance": 196.71,
|
||||||
"expenses": [],
|
"expenses": [
|
||||||
|
{
|
||||||
|
"date": "2026-02-14",
|
||||||
|
"item": "Hetzner CAX11 server (docfast-1, nbg1)",
|
||||||
|
"amount": 3.29,
|
||||||
|
"recurring": "monthly",
|
||||||
|
"notes": "First month prorated"
|
||||||
|
}
|
||||||
|
],
|
||||||
"income": []
|
"income": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,21 @@
|
||||||
- **Next:** Need human for: domain purchase, server deployment, Stripe setup.
|
- **Next:** Need human for: domain purchase, server deployment, Stripe setup.
|
||||||
- **Blockers:** Domain, Stripe, deployment access — all require human action.
|
- **Blockers:** Domain, Stripe, deployment access — all require human action.
|
||||||
|
|
||||||
|
## Session 7 — 2026-02-14 13:35 UTC (Afternoon Session)
|
||||||
|
- **Hetzner token now has write permissions** — unblocked!
|
||||||
|
- Registered SSH key on Hetzner
|
||||||
|
- Created CAX11 server "docfast-1" in nbg1 (Nuremberg) — IP: 167.235.156.214, €3.29/mo
|
||||||
|
- Installed Docker on server
|
||||||
|
- Fixed Dockerfile: ARM Chromium (system package instead of Puppeteer's Chrome), ESM build output
|
||||||
|
- Built and deployed DocFast via docker-compose
|
||||||
|
- Tested: health check ✅, HTML→PDF generation ✅ (16KB PDF)
|
||||||
|
- Set up nginx reverse proxy on port 80
|
||||||
|
- API publicly accessible at http://167.235.156.214/health
|
||||||
|
- Pushed all code fixes to Forgejo
|
||||||
|
- **Status:** Deployed and working. Needs DNS + SSL.
|
||||||
|
- **Next:** Human needs to point docfast.dev → 167.235.156.214 at INWX. Then certbot for SSL. Then Stripe.
|
||||||
|
- **Expenses:** ~€3.29/mo for server (first charge pending)
|
||||||
|
|
||||||
## Session 6 — 2026-02-14 13:33 UTC (Afternoon Session)
|
## Session 6 — 2026-02-14 13:33 UTC (Afternoon Session)
|
||||||
- Generated SSH key pair for server access (`/home/openclaw/.ssh/docfast`)
|
- Generated SSH key pair for server access (`/home/openclaw/.ssh/docfast`)
|
||||||
- Tested Hetzner API token — **read-only permissions**. Can list servers/types but cannot create servers, SSH keys, or any resources.
|
- Tested Hetzner API token — **read-only permissions**. Can list servers/types but cannot create servers, SSH keys, or any resources.
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
{
|
{
|
||||||
"phase": 1,
|
"phase": 1,
|
||||||
"phaseLabel": "Build MVP — Infrastructure Setup",
|
"phaseLabel": "Build MVP — Deployed, needs DNS + SSL",
|
||||||
"status": "blocked-hetzner-permissions",
|
"status": "deployed-needs-dns",
|
||||||
"product": "DocFast — HTML/Markdown to PDF API",
|
"product": "DocFast — HTML/Markdown to PDF API",
|
||||||
"currentPriority": "Hetzner API token needs read+write permissions. Current token is read-only (can list but not create resources). Once fixed: spin up CAX11, deploy DocFast, set up HTTPS.",
|
"currentPriority": "DNS: Point docfast.dev A record to 167.235.156.214. Then SSL via certbot. Then Stripe integration for billing.",
|
||||||
"infrastructure": {
|
"infrastructure": {
|
||||||
"domain": "docfast.dev",
|
"domain": "docfast.dev",
|
||||||
"registrar": "INWX",
|
"registrar": "INWX",
|
||||||
"hosting": "Hetzner Cloud (API access needed with write permissions)",
|
"hosting": "Hetzner Cloud",
|
||||||
"preferredVM": "CAX11 (ARM, 2 vCPU, 4GB, ~€3.29/mo)",
|
"server": "docfast-1 (CAX11, nbg1)",
|
||||||
"sshKey": "/home/openclaw/.ssh/docfast"
|
"serverIP": "167.235.156.214",
|
||||||
|
"sshKey": "/home/openclaw/.ssh/docfast",
|
||||||
|
"apiKey": "df_live_9760e44a3e732be0f8628a44e0cdbc040107499f6e8f457a"
|
||||||
},
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"file": "/home/openclaw/.openclaw/workspace/.credentials/docfast.env",
|
"file": "/home/openclaw/.openclaw/workspace/.credentials/docfast.env",
|
||||||
"keys": ["HETZNER_API_TOKEN", "STRIPE_PUBLISHABLE_KEY", "STRIPE_SECRET_KEY"],
|
"keys": ["HETZNER_API_TOKEN", "STRIPE_PUBLISHABLE_KEY", "STRIPE_SECRET_KEY"],
|
||||||
"NEVER_READ_DIRECTLY": true
|
"NEVER_READ_DIRECTLY": true
|
||||||
},
|
},
|
||||||
"blockers": ["Hetzner API token is read-only — needs write permissions to create servers/SSH keys"],
|
"blockers": ["DNS: Need docfast.dev A record pointed to 167.235.156.214 (human action at INWX)"],
|
||||||
"startDate": "2026-02-14",
|
"startDate": "2026-02-14",
|
||||||
"sessionCount": 6
|
"sessionCount": 7
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
FROM node:22-slim
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
# Chrome deps
|
# Install Chromium (works on ARM and x86)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \
|
chromium fonts-liberation \
|
||||||
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
|
||||||
libgbm1 libpango-1.0-0 libpangocairo-1.0-0 libcairo2 \
|
|
||||||
libasound2t64 libnspr4 libnss3 fonts-liberation \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY dist/ dist/
|
COPY dist/ dist/
|
||||||
|
COPY public/ public/
|
||||||
|
|
||||||
ENV PORT=3100
|
ENV PORT=3100
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
USER node
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
|
||||||
60
projects/business/src/pdf-api/dist/index.js
vendored
60
projects/business/src/pdf-api/dist/index.js
vendored
|
|
@ -1,25 +1,22 @@
|
||||||
"use strict";
|
import express from "express";
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
import helmet from "helmet";
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
import path from "path";
|
||||||
};
|
import { fileURLToPath } from "url";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import rateLimit from "express-rate-limit";
|
||||||
exports.app = void 0;
|
import { convertRouter } from "./routes/convert.js";
|
||||||
const express_1 = __importDefault(require("express"));
|
import { templatesRouter } from "./routes/templates.js";
|
||||||
const helmet_1 = __importDefault(require("helmet"));
|
import { healthRouter } from "./routes/health.js";
|
||||||
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
const convert_js_1 = require("./routes/convert.js");
|
import { usageMiddleware } from "./middleware/usage.js";
|
||||||
const templates_js_1 = require("./routes/templates.js");
|
import { getUsageStats } from "./middleware/usage.js";
|
||||||
const health_js_1 = require("./routes/health.js");
|
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||||
const auth_js_1 = require("./middleware/auth.js");
|
const app = express();
|
||||||
const browser_js_1 = require("./services/browser.js");
|
|
||||||
const app = (0, express_1.default)();
|
|
||||||
exports.app = app;
|
|
||||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||||
app.use((0, helmet_1.default)());
|
app.use(helmet());
|
||||||
app.use(express_1.default.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
app.use(express_1.default.text({ limit: "2mb", type: "text/*" }));
|
app.use(express.text({ limit: "2mb", type: "text/*" }));
|
||||||
// Rate limiting: 100 req/min for free tier
|
// Rate limiting: 100 req/min for free tier
|
||||||
const limiter = (0, express_rate_limit_1.default)({
|
const limiter = rateLimit({
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
max: 100,
|
max: 100,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
|
|
@ -27,12 +24,19 @@ const limiter = (0, express_rate_limit_1.default)({
|
||||||
});
|
});
|
||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
// Public
|
// Public
|
||||||
app.use("/health", health_js_1.healthRouter);
|
app.use("/health", healthRouter);
|
||||||
// Authenticated
|
// Authenticated
|
||||||
app.use("/v1/convert", auth_js_1.authMiddleware, convert_js_1.convertRouter);
|
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
||||||
app.use("/v1/templates", auth_js_1.authMiddleware, templates_js_1.templatesRouter);
|
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||||
// Root
|
// Admin: usage stats (protected by auth)
|
||||||
app.get("/", (_req, res) => {
|
app.get("/v1/usage", authMiddleware, (_req, res) => {
|
||||||
|
res.json(getUsageStats());
|
||||||
|
});
|
||||||
|
// Landing page
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
// API root (for programmatic discovery)
|
||||||
|
app.get("/api", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: "DocFast API",
|
name: "DocFast API",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|
@ -40,17 +44,18 @@ app.get("/", (_req, res) => {
|
||||||
endpoints: [
|
endpoints: [
|
||||||
"POST /v1/convert/html",
|
"POST /v1/convert/html",
|
||||||
"POST /v1/convert/markdown",
|
"POST /v1/convert/markdown",
|
||||||
|
"POST /v1/convert/url",
|
||||||
"POST /v1/templates/:id/render",
|
"POST /v1/templates/:id/render",
|
||||||
"GET /v1/templates",
|
"GET /v1/templates",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
async function start() {
|
async function start() {
|
||||||
await (0, browser_js_1.initBrowser)();
|
await initBrowser();
|
||||||
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
|
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
console.log("Shutting down...");
|
console.log("Shutting down...");
|
||||||
await (0, browser_js_1.closeBrowser)();
|
await closeBrowser();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
@ -60,3 +65,4 @@ start().catch((err) => {
|
||||||
console.error("Failed to start:", err);
|
console.error("Failed to start:", err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
export { app };
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
"use strict";
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.authMiddleware = authMiddleware;
|
|
||||||
const API_KEYS = new Set((process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim()));
|
const API_KEYS = new Set((process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim()));
|
||||||
function authMiddleware(req, res, next) {
|
export function authMiddleware(req, res, next) {
|
||||||
const header = req.headers.authorization;
|
const header = req.headers.authorization;
|
||||||
if (!header?.startsWith("Bearer ")) {
|
if (!header?.startsWith("Bearer ")) {
|
||||||
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>" });
|
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>" });
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
"use strict";
|
import { Router } from "express";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { renderPdf, renderUrlPdf } from "../services/browser.js";
|
||||||
exports.convertRouter = void 0;
|
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||||
const express_1 = require("express");
|
export const convertRouter = Router();
|
||||||
const browser_js_1 = require("../services/browser.js");
|
|
||||||
const markdown_js_1 = require("../services/markdown.js");
|
|
||||||
exports.convertRouter = (0, express_1.Router)();
|
|
||||||
// POST /v1/convert/html
|
// POST /v1/convert/html
|
||||||
exports.convertRouter.post("/html", async (req, res) => {
|
convertRouter.post("/html", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = typeof req.body === "string" ? { html: req.body } : req.body;
|
const body = typeof req.body === "string" ? { html: req.body } : req.body;
|
||||||
if (!body.html) {
|
if (!body.html) {
|
||||||
|
|
@ -16,8 +13,8 @@ exports.convertRouter.post("/html", async (req, res) => {
|
||||||
// Wrap bare HTML fragments
|
// Wrap bare HTML fragments
|
||||||
const fullHtml = body.html.includes("<html")
|
const fullHtml = body.html.includes("<html")
|
||||||
? body.html
|
? body.html
|
||||||
: (0, markdown_js_1.wrapHtml)(body.html, body.css);
|
: wrapHtml(body.html, body.css);
|
||||||
const pdf = await (0, browser_js_1.renderPdf)(fullHtml, {
|
const pdf = await renderPdf(fullHtml, {
|
||||||
format: body.format,
|
format: body.format,
|
||||||
landscape: body.landscape,
|
landscape: body.landscape,
|
||||||
margin: body.margin,
|
margin: body.margin,
|
||||||
|
|
@ -34,15 +31,15 @@ exports.convertRouter.post("/html", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// POST /v1/convert/markdown
|
// POST /v1/convert/markdown
|
||||||
exports.convertRouter.post("/markdown", async (req, res) => {
|
convertRouter.post("/markdown", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||||
if (!body.markdown) {
|
if (!body.markdown) {
|
||||||
res.status(400).json({ error: "Missing 'markdown' field" });
|
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const html = (0, markdown_js_1.markdownToHtml)(body.markdown, body.css);
|
const html = markdownToHtml(body.markdown, body.css);
|
||||||
const pdf = await (0, browser_js_1.renderPdf)(html, {
|
const pdf = await renderPdf(html, {
|
||||||
format: body.format,
|
format: body.format,
|
||||||
landscape: body.landscape,
|
landscape: body.landscape,
|
||||||
margin: body.margin,
|
margin: body.margin,
|
||||||
|
|
@ -58,3 +55,40 @@ exports.convertRouter.post("/markdown", async (req, res) => {
|
||||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// POST /v1/convert/url
|
||||||
|
convertRouter.post("/url", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
if (!body.url) {
|
||||||
|
res.status(400).json({ error: "Missing 'url' field" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Basic URL validation
|
||||||
|
try {
|
||||||
|
const parsed = new URL(body.url);
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
res.status(400).json({ error: "Only http/https URLs are supported" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
res.status(400).json({ error: "Invalid URL" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pdf = await renderUrlPdf(body.url, {
|
||||||
|
format: body.format,
|
||||||
|
landscape: body.landscape,
|
||||||
|
margin: body.margin,
|
||||||
|
printBackground: body.printBackground,
|
||||||
|
waitUntil: body.waitUntil,
|
||||||
|
});
|
||||||
|
const filename = body.filename || "page.pdf";
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Convert URL error:", err);
|
||||||
|
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
"use strict";
|
import { Router } from "express";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
export const healthRouter = Router();
|
||||||
exports.healthRouter = void 0;
|
healthRouter.get("/", (_req, res) => {
|
||||||
const express_1 = require("express");
|
|
||||||
exports.healthRouter = (0, express_1.Router)();
|
|
||||||
exports.healthRouter.get("/", (_req, res) => {
|
|
||||||
res.json({ status: "ok", version: "0.1.0" });
|
res.json({ status: "ok", version: "0.1.0" });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"use strict";
|
import { Router } from "express";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
import { renderPdf } from "../services/browser.js";
|
||||||
exports.templatesRouter = void 0;
|
import { templates, renderTemplate } from "../services/templates.js";
|
||||||
const express_1 = require("express");
|
export const templatesRouter = Router();
|
||||||
const browser_js_1 = require("../services/browser.js");
|
|
||||||
const templates_js_1 = require("../services/templates.js");
|
|
||||||
exports.templatesRouter = (0, express_1.Router)();
|
|
||||||
// GET /v1/templates — list available templates
|
// GET /v1/templates — list available templates
|
||||||
exports.templatesRouter.get("/", (_req, res) => {
|
templatesRouter.get("/", (_req, res) => {
|
||||||
const list = Object.entries(templates_js_1.templates).map(([id, t]) => ({
|
const list = Object.entries(templates).map(([id, t]) => ({
|
||||||
id,
|
id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
|
|
@ -16,17 +13,17 @@ exports.templatesRouter.get("/", (_req, res) => {
|
||||||
res.json({ templates: list });
|
res.json({ templates: list });
|
||||||
});
|
});
|
||||||
// POST /v1/templates/:id/render — render template to PDF
|
// POST /v1/templates/:id/render — render template to PDF
|
||||||
exports.templatesRouter.post("/:id/render", async (req, res) => {
|
templatesRouter.post("/:id/render", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const template = templates_js_1.templates[id];
|
const template = templates[id];
|
||||||
if (!template) {
|
if (!template) {
|
||||||
res.status(404).json({ error: `Template '${id}' not found` });
|
res.status(404).json({ error: `Template '${id}' not found` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
const html = (0, templates_js_1.renderTemplate)(id, data);
|
const html = renderTemplate(id, data);
|
||||||
const pdf = await (0, browser_js_1.renderPdf)(html, {
|
const pdf = await renderPdf(html, {
|
||||||
format: data._format || "A4",
|
format: data._format || "A4",
|
||||||
margin: data._margin,
|
margin: data._margin,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,19 @@
|
||||||
"use strict";
|
import puppeteer from "puppeteer";
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
||||||
};
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.initBrowser = initBrowser;
|
|
||||||
exports.closeBrowser = closeBrowser;
|
|
||||||
exports.renderPdf = renderPdf;
|
|
||||||
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
||||||
let browser = null;
|
let browser = null;
|
||||||
async function initBrowser() {
|
export async function initBrowser() {
|
||||||
browser = await puppeteer_1.default.launch({
|
const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined;
|
||||||
|
browser = await puppeteer.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
|
executablePath: execPath,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||||
});
|
});
|
||||||
console.log("Browser pool ready");
|
console.log("Browser pool ready");
|
||||||
}
|
}
|
||||||
async function closeBrowser() {
|
export async function closeBrowser() {
|
||||||
if (browser)
|
if (browser)
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
async function renderPdf(html, options = {}) {
|
export async function renderPdf(html, options = {}) {
|
||||||
if (!browser)
|
if (!browser)
|
||||||
throw new Error("Browser not initialized");
|
throw new Error("Browser not initialized");
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
@ -45,3 +39,29 @@ async function renderPdf(html, options = {}) {
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function renderUrlPdf(url, options = {}) {
|
||||||
|
if (!browser)
|
||||||
|
throw new Error("Browser not initialized");
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto(url, {
|
||||||
|
waitUntil: options.waitUntil || "networkidle0",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: options.format || "A4",
|
||||||
|
landscape: options.landscape || false,
|
||||||
|
printBackground: options.printBackground !== false,
|
||||||
|
margin: options.margin || {
|
||||||
|
top: "20mm",
|
||||||
|
right: "15mm",
|
||||||
|
bottom: "20mm",
|
||||||
|
left: "15mm",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Buffer.from(pdf);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
"use strict";
|
import { marked } from "marked";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.markdownToHtml = markdownToHtml;
|
|
||||||
exports.wrapHtml = wrapHtml;
|
|
||||||
const marked_1 = require("marked");
|
|
||||||
const defaultCss = `
|
const defaultCss = `
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
@ -23,11 +19,11 @@ th { background: #f8f8f8; font-weight: 600; }
|
||||||
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; }
|
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; }
|
||||||
img { max-width: 100%; }
|
img { max-width: 100%; }
|
||||||
`;
|
`;
|
||||||
function markdownToHtml(md, css) {
|
export function markdownToHtml(md, css) {
|
||||||
const html = marked_1.marked.parse(md, { async: false });
|
const html = marked.parse(md, { async: false });
|
||||||
return wrapHtml(html, css || defaultCss);
|
return wrapHtml(html, css || defaultCss);
|
||||||
}
|
}
|
||||||
function wrapHtml(body, css) {
|
export function wrapHtml(body, css) {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><style>${css || defaultCss}</style></head>
|
<html><head><meta charset="utf-8"><style>${css || defaultCss}</style></head>
|
||||||
<body>${body}</body></html>`;
|
<body>${body}</body></html>`;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
"use strict";
|
export const templates = {
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.templates = void 0;
|
|
||||||
exports.renderTemplate = renderTemplate;
|
|
||||||
exports.templates = {
|
|
||||||
invoice: {
|
invoice: {
|
||||||
name: "Invoice",
|
name: "Invoice",
|
||||||
description: "Professional invoice with line items, taxes, and payment details",
|
description: "Professional invoice with line items, taxes, and payment details",
|
||||||
|
|
@ -159,8 +155,8 @@ function renderReceipt(d) {
|
||||||
<hr><div class="center">Thank you!</div>
|
<hr><div class="center">Thank you!</div>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
function renderTemplate(id, data) {
|
export function renderTemplate(id, data) {
|
||||||
const template = exports.templates[id];
|
const template = templates[id];
|
||||||
if (!template)
|
if (!template)
|
||||||
throw new Error(`Template '${id}' not found`);
|
throw new Error(`Template '${id}' not found`);
|
||||||
return template.render(data);
|
return template.render(data);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import puppeteer, { Browser, Page } from "puppeteer";
|
||||||
let browser: Browser | null = null;
|
let browser: Browser | null = null;
|
||||||
|
|
||||||
export async function initBrowser(): Promise<void> {
|
export async function initBrowser(): Promise<void> {
|
||||||
|
const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined;
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
|
executablePath: execPath,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||||
});
|
});
|
||||||
console.log("Browser pool ready");
|
console.log("Browser pool ready");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue