Skip to content

Commit 694835c

Browse files
refactor(tools)!: gotenberg-render becomes the @corbits/gotenberg tool package (CL-8318) (#824)
* refactor(tools)!: gotenberg-render becomes the @corbits/gotenberg tool package Moves packages/gotenberg-render to tools/gotenberg, reshaping the markdown-to-PDF render step into an @intx/agent tool bundle (gotenberg_render_pdf) shaped like the other tool packages under tools/*. Configuration now comes from a declared gotenberg credential handle resolved at call time instead of a GOTENBERG_URL env var read by the render step itself. Adds a gotenberg service to a new root compose.yml so operators can run a Gotenberg server locally. * chore: refresh lockfile after rebase
1 parent f3a5ee1 commit 694835c

14 files changed

Lines changed: 701 additions & 4 deletions

‎bun.lock‎

Lines changed: 15 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎compose.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Local services for running a bench outside its test suites.
2+
#
3+
# Usage:
4+
# docker compose up -d
5+
services:
6+
gotenberg:
7+
image: gotenberg/gotenberg:8
8+
ports:
9+
- "3010:3000"

‎tools/gotenberg/LICENSE‎

Lines changed: 176 additions & 0 deletions
Large diffs are not rendered by default.

‎tools/gotenberg/README.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# @corbits/gotenberg
2+
3+
Renders a Markdown document to PDF through an operator-configured
4+
[Gotenberg](https://gotenberg.dev) server: a thin HTTP client and one
5+
`@intx/agent` tool bundle.
6+
7+
## Tools
8+
9+
- `gotenberg_render_pdf` — renders a Markdown document (`title`,
10+
`markdown`) to PDF and returns it as base64-encoded bytes plus a
11+
filename and MIME type.
12+
13+
## Credential
14+
15+
The bundle's env carries `credentials`, the harness's consumer-gated
16+
`CredentialCapability`. When it is absent, or
17+
`credentials.resolve("gotenberg")` throws — no bound handle, or a grant
18+
that doesn't authorize this consumer — the tool never throws itself: it
19+
returns a completed result with `isError: true` and content naming the
20+
source "not connected".
21+
22+
This package declares one credential handle, `gotenberg`, in its
23+
`package.json`'s `interchange.credentials` field. The bound credential's
24+
origin is the operator's Gotenberg server address — connect it once in
25+
Settings · Connections, and every workflow that pins `@corbits/gotenberg`
26+
and binds this handle resolves the same bench-owned credential.
27+
28+
## Running Gotenberg locally
29+
30+
```sh
31+
docker compose up -d gotenberg
32+
```
33+
34+
or directly:
35+
36+
```sh
37+
docker run --rm -p 3010:3000 gotenberg/gotenberg:8
38+
```
39+
40+
Gotenberg is stateless: no migrations, no shared state, scales
41+
horizontally.
42+
43+
## Usage
44+
45+
```ts
46+
import { gotenbergTools } from "@corbits/gotenberg";
47+
48+
const agent = defineAgent({
49+
// ...
50+
tools: [gotenbergTools],
51+
});
52+
```
53+
54+
## Running tests
55+
56+
```
57+
cd tools/gotenberg && bun test
58+
```

‎tools/gotenberg/package.json‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@corbits/gotenberg",
3+
"version": "0.0.1",
4+
"description": "Renders Markdown to PDF through an operator-configured Gotenberg server: a minimal client and an @intx/agent tool bundle exposing gotenberg_render_pdf",
5+
"license": "LGPL-2.1-or-later",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/corbitsdev/workbench.git",
9+
"directory": "tools/gotenberg"
10+
},
11+
"files": [
12+
"src",
13+
"LICENSE",
14+
"README.md"
15+
],
16+
"type": "module",
17+
"exports": {
18+
".": "./src/index.ts"
19+
},
20+
"publishConfig": {
21+
"access": "public"
22+
},
23+
"scripts": {
24+
"typecheck": "tsc --noEmit",
25+
"test": "bun test"
26+
},
27+
"devDependencies": {
28+
"@types/bun": "catalog:",
29+
"arktype": "catalog:",
30+
"typescript": "catalog:"
31+
},
32+
"peerDependencies": {
33+
"@intx/agent": "0.3.0",
34+
"@intx/types": "0.3.0"
35+
},
36+
"interchange": {
37+
"credentials": [
38+
{
39+
"handle": "gotenberg"
40+
}
41+
]
42+
}
43+
}

‎tools/gotenberg/src/client.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { GotenbergRenderError, renderMarkdownToPdf } from "./client";
3+
4+
describe("renderMarkdownToPdf", () => {
5+
test("posts the markdown and index template as a relative request", async () => {
6+
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
7+
let capturedUrl: string | undefined;
8+
let capturedForm: FormData | undefined;
9+
const fetchStub = async (url: string | URL | Request, init?: RequestInit) => {
10+
capturedUrl = String(url);
11+
capturedForm = init?.body as FormData;
12+
return new Response(pdfBytes, { status: 200 });
13+
};
14+
15+
const result = await renderMarkdownToPdf(
16+
{ title: "Acme Diligence Brief", markdown: "# Hello" },
17+
fetchStub,
18+
);
19+
20+
expect(result).toEqual(pdfBytes);
21+
expect(capturedUrl).toBe("/forms/chromium/convert/markdown");
22+
const files = capturedForm?.getAll("files") ?? [];
23+
expect(files).toHaveLength(2);
24+
});
25+
26+
test("wraps a network failure in GotenbergRenderError", async () => {
27+
const fetchStub = async () => {
28+
throw new Error("ECONNREFUSED");
29+
};
30+
31+
await expect(
32+
renderMarkdownToPdf({ title: "t", markdown: "m" }, fetchStub),
33+
).rejects.toBeInstanceOf(GotenbergRenderError);
34+
});
35+
36+
test("wraps a non-2xx response in GotenbergRenderError", async () => {
37+
const fetchStub = async () => new Response("boom", { status: 500 });
38+
39+
await expect(
40+
renderMarkdownToPdf({ title: "t", markdown: "m" }, fetchStub),
41+
).rejects.toBeInstanceOf(GotenbergRenderError);
42+
});
43+
});

‎tools/gotenberg/src/client.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Talks to one Gotenberg server (https://gotenberg.dev) over its Chromium
2+
// module's Markdown route: POST an `index.html` that inlines a Markdown
3+
// file via Gotenberg's `toHTML` template helper, get a rendered PDF back.
4+
// Gotenberg is stateless and holds no workbench data of its own — every
5+
// call is a one-shot conversion, nothing to provision or migrate. The
6+
// caller's `fetchImpl` is expected to already be pinned to the operator's
7+
// Gotenberg origin (the mediated credential's `fetch`), so this module
8+
// only ever requests a relative path.
9+
export type GotenbergFetch = (
10+
input: string | URL | Request,
11+
init?: RequestInit,
12+
) => Promise<Response>;
13+
14+
const MARKDOWN_ROUTE = "/forms/chromium/convert/markdown";
15+
16+
function escapeHtml(value: string): string {
17+
return value
18+
.replace(/&/g, "&amp;")
19+
.replace(/</g, "&lt;")
20+
.replace(/>/g, "&gt;")
21+
.replace(/"/g, "&quot;");
22+
}
23+
24+
function indexTemplateFor(title: string): string {
25+
return (
26+
`<!doctype html><html><head><meta charset="utf-8">` +
27+
`<title>${escapeHtml(title)}</title></head>` +
28+
`<body>{{ toHTML "body.md" }}</body></html>`
29+
);
30+
}
31+
32+
export class GotenbergRenderError extends Error {}
33+
34+
/**
35+
* Converts one Markdown document to PDF bytes via a Gotenberg server.
36+
* Throws `GotenbergRenderError` on any transport or non-2xx failure.
37+
*/
38+
export async function renderMarkdownToPdf(
39+
input: { readonly title: string; readonly markdown: string },
40+
fetchImpl: GotenbergFetch,
41+
): Promise<Uint8Array> {
42+
const form = new FormData();
43+
form.append(
44+
"files",
45+
new Blob([indexTemplateFor(input.title)], { type: "text/html" }),
46+
"index.html",
47+
);
48+
form.append("files", new Blob([input.markdown], { type: "text/markdown" }), "body.md");
49+
50+
let response: Response;
51+
try {
52+
response = await fetchImpl(MARKDOWN_ROUTE, { method: "POST", body: form });
53+
} catch (err) {
54+
throw new GotenbergRenderError(
55+
`Could not reach Gotenberg: ${err instanceof Error ? err.message : String(err)}`,
56+
);
57+
}
58+
if (!response.ok) {
59+
throw new GotenbergRenderError(
60+
`Gotenberg rejected the render: ${response.status} ${response.statusText}`,
61+
);
62+
}
63+
return new Uint8Array(await response.arrayBuffer());
64+
}

‎tools/gotenberg/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { renderMarkdownToPdf, GotenbergRenderError } from "./client";
2+
export type { GotenbergFetch } from "./client";
3+
export { GOTENBERG_RENDER_PDF_TOOL, gotenbergTools } from "./tool";
4+
export type { GotenbergEnv } from "./tool";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Proves this package's own `package.json` satisfies the vendored
2+
// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) with
3+
// an `interchange.credentials` declaration the deploy-time
4+
// `ToolPackageManifest` harvests into `topLevel[].credentials`.
5+
import { expect, test } from "bun:test";
6+
import { type } from "arktype";
7+
import { PackageJSON } from "@intx/types/package-json";
8+
9+
import packageJson from "../package.json";
10+
11+
test("package.json parses against the vendored PackageJSON manifest schema", () => {
12+
const parsed = PackageJSON(packageJson);
13+
expect(parsed instanceof type.errors).toBe(false);
14+
});
15+
16+
test("declares exactly one credential handle: gotenberg", () => {
17+
const parsed = PackageJSON(packageJson);
18+
if (parsed instanceof type.errors) {
19+
throw new Error(parsed.summary);
20+
}
21+
expect(parsed.interchange?.credentials).toEqual([{ handle: "gotenberg" }]);
22+
});

‎tools/gotenberg/src/tool.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { expect, test } from "bun:test";
2+
import type { ToolCall } from "@intx/types/runtime";
3+
import type { CredentialCapability, MediatedCredential } from "@intx/types";
4+
5+
import { GOTENBERG_RENDER_PDF_TOOL, gotenbergTools } from "./tool";
6+
import type { GotenbergEnv } from "./tool";
7+
8+
const CALL: ToolCall = {
9+
id: "call_1",
10+
name: GOTENBERG_RENDER_PDF_TOOL,
11+
arguments: { title: "Acme Brief", markdown: "# Hello" },
12+
};
13+
14+
/**
15+
* A fake `credentials` capability mirroring the platform's own
16+
* `createCredentialCapability`/`createHttpCredentialProvider` shape: a
17+
* bound handle resolves to a mediated `fetch` pinned to the operator's
18+
* Gotenberg origin; an unbound handle throws.
19+
*/
20+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
21+
22+
function fakeCredentials(fetchImpl: FetchLike | undefined): CredentialCapability {
23+
return {
24+
resolve(handle: string): Promise<MediatedCredential> {
25+
if (fetchImpl === undefined) {
26+
return Promise.reject(new Error(`no credential is bound to handle "${handle}"`));
27+
}
28+
return Promise.resolve({
29+
kind: "http",
30+
fetch: fetchImpl,
31+
dispose: () => {},
32+
});
33+
},
34+
};
35+
}
36+
37+
function fakeEnv(credentials: CredentialCapability | undefined): GotenbergEnv {
38+
return { credentials } as unknown as GotenbergEnv;
39+
}
40+
41+
test("declares the gotenberg_render_pdf tool", () => {
42+
const bundle = gotenbergTools(fakeEnv(fakeCredentials(async () => new Response())));
43+
expect(bundle.definitions.map((d) => d.name)).toEqual([GOTENBERG_RENDER_PDF_TOOL]);
44+
});
45+
46+
test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => {
47+
const bundle = gotenbergTools(fakeEnv(fakeCredentials(undefined)));
48+
const result = await bundle.run(CALL, new AbortController().signal);
49+
expect(result.isError).toBe(true);
50+
expect(result.content).toMatch(/not connected/i);
51+
});
52+
53+
test("degrades the same way when the step carries no credentials capability at all", async () => {
54+
const bundle = gotenbergTools(fakeEnv(undefined));
55+
const result = await bundle.run(CALL, new AbortController().signal);
56+
expect(result.isError).toBe(true);
57+
expect(result.content).toMatch(/not connected/i);
58+
});
59+
60+
test("rejects a missing markdown argument without calling the network", async () => {
61+
let called = false;
62+
const bundle = gotenbergTools(
63+
fakeEnv(
64+
fakeCredentials(async () => {
65+
called = true;
66+
return new Response();
67+
}),
68+
),
69+
);
70+
const result = await bundle.run(
71+
{ id: "call_2", name: GOTENBERG_RENDER_PDF_TOOL, arguments: { title: "t" } },
72+
new AbortController().signal,
73+
);
74+
expect(called).toBe(false);
75+
expect(result.isError).toBe(true);
76+
expect(result.content).toContain("markdown");
77+
});
78+
79+
test("returns the rendered PDF as base64 content on a successful call", async () => {
80+
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
81+
const bundle = gotenbergTools(
82+
fakeEnv(fakeCredentials(async () => new Response(pdfBytes, { status: 200 }))),
83+
);
84+
const result = await bundle.run(CALL, new AbortController().signal);
85+
expect(result.isError).toBeUndefined();
86+
const parsed = JSON.parse(result.content as string) as {
87+
filename: string;
88+
mimeType: string;
89+
pdfBase64: string;
90+
};
91+
expect(parsed.filename).toBe("Acme Brief.pdf");
92+
expect(parsed.mimeType).toBe("application/pdf");
93+
expect(Buffer.from(parsed.pdfBase64, "base64")).toEqual(Buffer.from(pdfBytes));
94+
});
95+
96+
test("degrades to an error result (never throws) when the underlying call fails", async () => {
97+
const bundle = gotenbergTools(
98+
fakeEnv(fakeCredentials(async () => new Response("nope", { status: 500 }))),
99+
);
100+
const result = await bundle.run(CALL, new AbortController().signal);
101+
expect(result.isError).toBe(true);
102+
});

0 commit comments

Comments
 (0)