Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

176 changes: 176 additions & 0 deletions packages/gotenberg-render/LICENSE

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions packages/gotenberg-render/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# @corbits/gotenberg-render

Renders a Markdown [Library](../artifacts-hub) artifact to PDF through an
operator-configured [Gotenberg](https://gotenberg.dev) server (CL-6499),
and hands the resulting bytes to a caller-supplied sink to persist as a
new Library artifact — no new download mechanism, no artifact-shaped
document invented along the way.

## What this package does

- `resolveGotenbergConfig(env)` — reads `GOTENBERG_URL` and returns
`{ baseUrl }`, or `null` when it's unset. `null` means the render
capability is simply absent for this bench: callers must check for it
before ever offering a "Download as PDF" action, rather than showing a
button that errors or no-ops when clicked.
- `renderMarkdownToPdf(config, { title, markdown })` — POSTs the Markdown
to Gotenberg's Chromium `convert/markdown` route and returns the PDF as
`Uint8Array`. Throws `GotenbergRenderError` on any transport or non-2xx
failure.
- `renderMarkdownArtifactToPdf(config, source, sink, context)` —
orchestrates the above end to end: renders, then calls `sink.savePdf`
to persist the PDF as a new artifact. On any failure it reports through
`@corbits/error-sink` and returns a plain-language message
("Couldn't build the PDF. Try again shortly.") plus a `refId` — never a
raw HTTP status or internal error to the person waiting on their brief.

`sink` and the Markdown `source` are both injected ports, not a concrete
artifact store: the caller wires in however it already reads a Library
artifact's content and however it already persists a new one (the tenant
Library's multipart `/upload` route being the one that currently accepts
arbitrary binary content — see "What's still missing" below).

## Turning this on

1. **Run a Gotenberg server.** It's normally a container; the official
image needs no extra configuration for Markdown-to-PDF:

```sh
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```

Point it at wherever you run containers for this bench (a sidecar
service, a small Railway service, etc.) — Gotenberg is stateless, so it
scales horizontally with zero shared state.

2. **Set `GOTENBERG_URL`** on the process that calls this package to that
server's base URL, e.g. `http://gotenberg:3000` or
`https://gotenberg.internal.example.com`. Leaving it unset (the
default) keeps the PDF-render capability off entirely — nothing here
errors at startup either way.

3. **Wire a `PdfArtifactSink`** that persists the returned bytes as a new
Library artifact with `mimeType: "application/pdf"`, and a source that
reads the Markdown artifact's content, then call
`renderMarkdownArtifactToPdf`.

## What's still missing for an end-to-end brief

Rendering itself works against any Markdown + a running Gotenberg
server. Two integration gaps remain before a real due-diligence brief can
go artifact-in, PDF-artifact-out with no glue code:

- The workflow-run artifact surface
(`@corbits/artifacts-hub`'s `createWorkflowArtifactRoutes`, the one a
Scout run authenticates against) has `POST /` (create) and
`GET /recent` (list) but no `GET /:id` to fetch one artifact's content
back — so a workflow run can't yet read the Markdown brief it just
saved in order to hand it to this package.
- That same workflow surface stores `content` as a JSON string capped at
64k characters — it has no path for binary content. The only route
today that accepts arbitrary bytes (`POST /upload` on the tenant
Library surface) is authenticated by browser tenant session, not a
workflow run's sidecar token. Persisting a rendered PDF from inside a
workflow run needs one of those two surfaces extended; this package's
`PdfArtifactSink` port is deliberately shaped so that extension is a
drop-in once it exists.
23 changes: 23 additions & 0 deletions packages/gotenberg-render/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@corbits/gotenberg-render",
"private": true,
"description": "Renders a Markdown Library artifact to PDF via an operator-configured Gotenberg server (CL-6499): config resolution that treats an unset endpoint as an absent capability, a thin Gotenberg HTTP client, and an orchestration step that reports failures through @corbits/error-sink",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@corbits/error-sink": "workspace:*",
"arktype": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
52 changes: 52 additions & 0 deletions packages/gotenberg-render/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import { GotenbergRenderError, renderMarkdownToPdf } from "./client";

const CONFIG = { baseUrl: "http://gotenberg.internal:3000" };

describe("renderMarkdownToPdf", () => {
test("posts the markdown and index template, returning the PDF bytes", async () => {
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
let capturedUrl: string | undefined;
let capturedForm: FormData | undefined;
const fetchStub = (async (
url: string | URL | Request,
init?: RequestInit,
) => {
capturedUrl = String(url);
capturedForm = init?.body as FormData;
return new Response(pdfBytes, { status: 200 });
}) as unknown as typeof fetch;

const result = await renderMarkdownToPdf(
CONFIG,
{ title: "Acme Diligence Brief", markdown: "# Hello" },
fetchStub,
);

expect(result).toEqual(pdfBytes);
expect(capturedUrl).toBe(
"http://gotenberg.internal:3000/forms/chromium/convert/markdown",
);
const files = capturedForm?.getAll("files") ?? [];
expect(files).toHaveLength(2);
});

test("wraps a network failure in GotenbergRenderError", async () => {
const fetchStub = (async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;

await expect(
renderMarkdownToPdf(CONFIG, { title: "t", markdown: "m" }, fetchStub),
).rejects.toBeInstanceOf(GotenbergRenderError);
});

test("wraps a non-2xx response in GotenbergRenderError", async () => {
const fetchStub = (async () =>
new Response("boom", { status: 500 })) as unknown as typeof fetch;

await expect(
renderMarkdownToPdf(CONFIG, { title: "t", markdown: "m" }, fetchStub),
).rejects.toBeInstanceOf(GotenbergRenderError);
});
});
69 changes: 69 additions & 0 deletions packages/gotenberg-render/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Talks to one Gotenberg server (https://gotenberg.dev) over its Chromium
// module's Markdown route: POST an `index.html` that inlines a Markdown
// file via Gotenberg's `toHTML` template helper, get a rendered PDF back.
// Gotenberg is stateless and holds no workbench data of its own — every
// call is a one-shot conversion, nothing to provision or migrate.
export type GotenbergFetch = typeof fetch;

const MARKDOWN_ROUTE = "/forms/chromium/convert/markdown";

function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

function indexTemplateFor(title: string): string {
return (
`<!doctype html><html><head><meta charset="utf-8">` +
`<title>${escapeHtml(title)}</title></head>` +
`<body>{{ toHTML "body.md" }}</body></html>`
);
}

export class GotenbergRenderError extends Error {}

/**
* Converts one Markdown document to PDF bytes via a Gotenberg server.
* Throws `GotenbergRenderError` on any transport or non-2xx failure —
* callers report it through `@corbits/error-sink`, never swallow it.
*/
export async function renderMarkdownToPdf(
config: { readonly baseUrl: string },
input: { readonly title: string; readonly markdown: string },
fetchImpl: GotenbergFetch = fetch,
): Promise<Uint8Array> {
const form = new FormData();
form.append(
"files",
new Blob([indexTemplateFor(input.title)], { type: "text/html" }),
"index.html",
);
form.append(
"files",
new Blob([input.markdown], { type: "text/markdown" }),
"body.md",
);

let response: Response;
try {
response = await fetchImpl(`${config.baseUrl}${MARKDOWN_ROUTE}`, {
method: "POST",
body: form,
});
} catch (err) {
throw new GotenbergRenderError(
`Could not reach Gotenberg at ${config.baseUrl}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
if (!response.ok) {
throw new GotenbergRenderError(
`Gotenberg rejected the render: ${response.status} ${response.statusText}`,
);
}
return new Uint8Array(await response.arrayBuffer());
}
24 changes: 24 additions & 0 deletions packages/gotenberg-render/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { resolveGotenbergConfig } from "./config";

describe("resolveGotenbergConfig", () => {
test("returns null when GOTENBERG_URL is unset — the capability is absent", () => {
expect(resolveGotenbergConfig({})).toBeNull();
});

test("returns null when GOTENBERG_URL is blank", () => {
expect(resolveGotenbergConfig({ GOTENBERG_URL: " " })).toBeNull();
});

test("resolves a configured URL, trimming a trailing slash", () => {
expect(
resolveGotenbergConfig({ GOTENBERG_URL: "http://gotenberg:3000/" }),
).toEqual({ baseUrl: "http://gotenberg:3000" });
});

test("throws on a value that isn't a URL", () => {
expect(() =>
resolveGotenbergConfig({ GOTENBERG_URL: "not-a-url" }),
).toThrow();
});
});
33 changes: 33 additions & 0 deletions packages/gotenberg-render/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Resolves the operator-configured Gotenberg endpoint (CL-6499). Gotenberg
// is an optional, self-hosted PDF-rendering service — the operator points
// us at it with one URL, exactly like any other pluggable external
// endpoint. Absence is a supported, ordinary state, not a config error: a
// bench with no `GOTENBERG_URL` set simply never gains the "render to
// PDF" capability, so callers must treat `null` as "capability absent"
// rather than retrying or logging a warning.
import { type } from "arktype";

const GotenbergUrl = type("string.url");

export type GotenbergConfig = {
readonly baseUrl: string;
};

/**
* Reads `GOTENBERG_URL` from the given env map. Returns `null` when unset
* or blank — the expected shape for a bench that hasn't opted in. Throws
* only when the operator set the variable to something that isn't a
* parseable URL, since that's a genuine misconfiguration worth failing on
* loudly rather than silently treating as "absent".
*/
export function resolveGotenbergConfig(
env: Record<string, string | undefined>,
): GotenbergConfig | null {
const raw = env.GOTENBERG_URL?.trim();
if (raw === undefined || raw === "") return null;
const parsed = GotenbergUrl(raw);
if (parsed instanceof type.errors) {
throw new Error(`GOTENBERG_URL is not a valid URL: ${parsed.summary}`);
}
return { baseUrl: parsed.replace(/\/+$/, "") };
}
10 changes: 10 additions & 0 deletions packages/gotenberg-render/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { resolveGotenbergConfig, type GotenbergConfig } from "./config";
export { renderMarkdownToPdf, GotenbergRenderError } from "./client";
export {
renderMarkdownArtifactToPdf,
type MarkdownArtifactSource,
type PdfArtifactSink,
type SavedPdfArtifact,
type RenderContext,
type RenderBriefResult,
} from "./render";
Loading
Loading