@robodev-ai/runtime 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/compile-invoke.test.ts +1 -0
- package/src/index.ts +20 -1
- package/src/invoke.ts +3 -0
- package/src/local-jobs.test.ts +1 -0
- package/src/local-jobs.ts +1 -0
- package/src/local-pdf.test.ts +167 -0
- package/src/local-pdf.ts +195 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine,
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine, local file storage, and the offline PDF hop client. Shared by hosted Starbase and `robodev dev`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"pg": "^8.16.3",
|
|
33
33
|
"zod": "^3.25.76",
|
|
34
34
|
"zod-to-json-schema": "^3.24.6",
|
|
35
|
-
"@robodev-ai/sdk": "0.
|
|
35
|
+
"@robodev-ai/sdk": "0.12.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/busboy": "^1.5.4",
|
|
@@ -81,6 +81,7 @@ function stubClients(): RouteClients {
|
|
|
81
81
|
destroy: missing,
|
|
82
82
|
},
|
|
83
83
|
storage: { upload: missing, get: missing, getUrl: missing, delete: missing, list: missing },
|
|
84
|
+
pdf: { fromHtml: missing },
|
|
84
85
|
push: { send: missing },
|
|
85
86
|
jobs: { enqueue: missing },
|
|
86
87
|
sockets: { send: missing, broadcast: missing },
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* offline `robodev dev` loop: deploy-file classification, the esbuild compile, module
|
|
4
4
|
* loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth,
|
|
5
5
|
* the socket engine, local file storage, the offline `robodev dev` jobs engine,
|
|
6
|
-
*
|
|
6
|
+
* email template rendering, and the offline PDF hop client.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export {
|
|
@@ -323,3 +323,22 @@ export {
|
|
|
323
323
|
verifyLocalDevIdentityAssertion,
|
|
324
324
|
verifyLocalGoogleState,
|
|
325
325
|
} from "./local-google.js";
|
|
326
|
+
|
|
327
|
+
export {
|
|
328
|
+
DEFAULT_STARBASE_URL,
|
|
329
|
+
PDF_DEFAULT_MARGIN_IN,
|
|
330
|
+
PDF_HOP_TIMEOUT_MS,
|
|
331
|
+
PDF_HTML_MAX_BYTES,
|
|
332
|
+
PDF_PAGE_FORMATS,
|
|
333
|
+
PDF_RESPONSE_MAX_BYTES,
|
|
334
|
+
brokerPdfUrl,
|
|
335
|
+
createHopPdfClient,
|
|
336
|
+
hopError,
|
|
337
|
+
hopPdfBodySchema,
|
|
338
|
+
hopStarbaseUrl,
|
|
339
|
+
isPdfBytes,
|
|
340
|
+
parsePdfHtml,
|
|
341
|
+
pdfFromHtmlOptionsSchema,
|
|
342
|
+
resolvePdfOptions,
|
|
343
|
+
type ResolvedPdfOptions,
|
|
344
|
+
} from "./local-pdf.js";
|
package/src/invoke.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
EmailClient,
|
|
6
6
|
JobsClient,
|
|
7
7
|
LlmClient,
|
|
8
|
+
PdfClient,
|
|
8
9
|
PushClient,
|
|
9
10
|
RobodevDb,
|
|
10
11
|
SocketsClient,
|
|
@@ -28,6 +29,7 @@ export type RouteClients = {
|
|
|
28
29
|
llm: LlmClient;
|
|
29
30
|
agent: AgentClient;
|
|
30
31
|
storage: StorageClient;
|
|
32
|
+
pdf: PdfClient;
|
|
31
33
|
push: PushClient;
|
|
32
34
|
jobs: JobsClient;
|
|
33
35
|
sockets: SocketsClient;
|
|
@@ -179,6 +181,7 @@ export async function runRoute(input: RunRouteInput): Promise<RouteOutcome> {
|
|
|
179
181
|
llm: clients.llm,
|
|
180
182
|
agent: clients.agent,
|
|
181
183
|
storage: clients.storage,
|
|
184
|
+
pdf: clients.pdf,
|
|
182
185
|
env: clients.env,
|
|
183
186
|
params,
|
|
184
187
|
query: query as never,
|
package/src/local-jobs.test.ts
CHANGED
|
@@ -68,6 +68,7 @@ function mockClients(): RouteClients {
|
|
|
68
68
|
return { objects: [], total: 0 };
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
|
+
pdf: { fromHtml: async () => Buffer.from("%PDF-1.4") },
|
|
71
72
|
push: { send: () => notConfigured("push_not_configured") },
|
|
72
73
|
jobs: { enqueue: async () => ({ id: "nested" }) },
|
|
73
74
|
sockets: {
|
package/src/local-jobs.ts
CHANGED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_STARBASE_URL,
|
|
5
|
+
brokerPdfUrl,
|
|
6
|
+
createHopPdfClient,
|
|
7
|
+
hopStarbaseUrl,
|
|
8
|
+
parsePdfHtml,
|
|
9
|
+
resolvePdfOptions,
|
|
10
|
+
} from "./local-pdf.js";
|
|
11
|
+
|
|
12
|
+
function pdfResponse(body: Uint8Array | string = "%PDF-1.4 hop", status = 200): Response {
|
|
13
|
+
const bytes = typeof body === "string" ? Buffer.from(body) : Buffer.from(body);
|
|
14
|
+
return new Response(bytes, {
|
|
15
|
+
status,
|
|
16
|
+
headers: { "content-type": "application/pdf" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("brokerPdfUrl strips trailing slash", () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
brokerPdfUrl("https://robodev.povio.dev/"),
|
|
23
|
+
"https://robodev.povio.dev/internal/pdf",
|
|
24
|
+
);
|
|
25
|
+
assert.equal(brokerPdfUrl("http://localhost:4000"), "http://localhost:4000/internal/pdf");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("hopStarbaseUrl defaults to production and honors STARBASE_URL", () => {
|
|
29
|
+
assert.equal(hopStarbaseUrl({}), DEFAULT_STARBASE_URL);
|
|
30
|
+
assert.equal(hopStarbaseUrl({ STARBASE_URL: "http://localhost:4000/" }), "http://localhost:4000");
|
|
31
|
+
assert.equal(hopStarbaseUrl({ STARBASE_URL: "" }), "");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parsePdfHtml rejects empty and oversized html", () => {
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => parsePdfHtml(""),
|
|
37
|
+
(err: unknown) => {
|
|
38
|
+
return err instanceof Error && err.message === "invalid_request";
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
assert.throws(
|
|
42
|
+
() => parsePdfHtml(" "),
|
|
43
|
+
(err: unknown) => {
|
|
44
|
+
return err instanceof Error && err.message === "invalid_request";
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
assert.throws(
|
|
48
|
+
() => parsePdfHtml(1),
|
|
49
|
+
(err: unknown) => {
|
|
50
|
+
return err instanceof Error && err.message === "invalid_request";
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
const huge = "x".repeat(512 * 1024 + 1);
|
|
54
|
+
assert.throws(
|
|
55
|
+
() => parsePdfHtml(huge),
|
|
56
|
+
(err: unknown) => {
|
|
57
|
+
return err instanceof Error && err.message === "pdf_too_large";
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
assert.equal(parsePdfHtml(" <p>ok</p> "), "<p>ok</p>");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("resolvePdfOptions applies defaults and rejects bad values", () => {
|
|
64
|
+
assert.deepEqual(resolvePdfOptions(), {
|
|
65
|
+
format: "A4",
|
|
66
|
+
landscape: false,
|
|
67
|
+
margin: { top: 0.39, right: 0.39, bottom: 0.39, left: 0.39 },
|
|
68
|
+
});
|
|
69
|
+
assert.deepEqual(resolvePdfOptions({ format: "Letter", landscape: true, margin: 0.5 }), {
|
|
70
|
+
format: "Letter",
|
|
71
|
+
landscape: true,
|
|
72
|
+
margin: { top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 },
|
|
73
|
+
});
|
|
74
|
+
assert.deepEqual(resolvePdfOptions({ margin: { top: 0.1 } }), {
|
|
75
|
+
format: "A4",
|
|
76
|
+
landscape: false,
|
|
77
|
+
margin: { top: 0.1, right: 0.39, bottom: 0.39, left: 0.39 },
|
|
78
|
+
});
|
|
79
|
+
assert.throws(() => resolvePdfOptions({ format: "A3" as "A4" }), /invalid_request/);
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => resolvePdfOptions({ landscape: "yes" as unknown as boolean }),
|
|
82
|
+
/invalid_request/,
|
|
83
|
+
);
|
|
84
|
+
assert.throws(() => resolvePdfOptions({ margin: 2.1 }), /invalid_request/);
|
|
85
|
+
assert.throws(() => resolvePdfOptions({ margin: { left: -1 } }), /invalid_request/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("createHopPdfClient uses process STARBASE_URL and maps hop error codes", async () => {
|
|
89
|
+
const previous = process.env.STARBASE_URL;
|
|
90
|
+
process.env.STARBASE_URL = "http://starbase.test:4000/";
|
|
91
|
+
let url = "";
|
|
92
|
+
const client = createHopPdfClient({
|
|
93
|
+
fetch: async (input) => {
|
|
94
|
+
url = String(input);
|
|
95
|
+
return new Response(JSON.stringify({ error: "pdf_rate_limited" }), {
|
|
96
|
+
status: 429,
|
|
97
|
+
headers: { "content-type": "application/json" },
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
() => client.fromHtml("<html>hi</html>"),
|
|
103
|
+
(err: unknown) => {
|
|
104
|
+
return err instanceof Error && err.message === "pdf_rate_limited";
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
assert.equal(url, "http://starbase.test:4000/internal/pdf");
|
|
108
|
+
if (previous === undefined) delete process.env.STARBASE_URL;
|
|
109
|
+
else process.env.STARBASE_URL = previous;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("createHopPdfClient defaults to production URL", async () => {
|
|
113
|
+
const previous = process.env.STARBASE_URL;
|
|
114
|
+
delete process.env.STARBASE_URL;
|
|
115
|
+
let url = "";
|
|
116
|
+
const client = createHopPdfClient({
|
|
117
|
+
fetch: async (input) => {
|
|
118
|
+
url = String(input);
|
|
119
|
+
return pdfResponse();
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const pdf = await client.fromHtml("<html>ok</html>");
|
|
123
|
+
assert.equal(url, `${DEFAULT_STARBASE_URL}/internal/pdf`);
|
|
124
|
+
assert.ok(pdf.toString("utf8").startsWith("%PDF"));
|
|
125
|
+
if (previous === undefined) delete process.env.STARBASE_URL;
|
|
126
|
+
else process.env.STARBASE_URL = previous;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("createHopPdfClient maps unconfigured blank STARBASE_URL and network failure", async () => {
|
|
130
|
+
const blank = createHopPdfClient({ baseUrl: "" });
|
|
131
|
+
await assert.rejects(() => blank.fromHtml("<p>x</p>"), /pdf_unconfigured/);
|
|
132
|
+
|
|
133
|
+
const down = createHopPdfClient({
|
|
134
|
+
baseUrl: "http://localhost:9",
|
|
135
|
+
fetch: async () => {
|
|
136
|
+
throw new Error("connect ECONNREFUSED");
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
await assert.rejects(() => down.fromHtml("<p>x</p>"), /pdf_failed/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("createHopPdfClient maps JSON hop codes and non-PDF 200", async () => {
|
|
143
|
+
const codes = ["invalid_request", "pdf_unconfigured", "pdf_too_large", "pdf_failed"] as const;
|
|
144
|
+
for (const code of codes) {
|
|
145
|
+
const client = createHopPdfClient({
|
|
146
|
+
baseUrl: "https://robodev.povio.dev",
|
|
147
|
+
fetch: async () =>
|
|
148
|
+
new Response(JSON.stringify({ error: code }), {
|
|
149
|
+
status: 400,
|
|
150
|
+
headers: { "content-type": "application/json" },
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
() => client.fromHtml("<p>x</p>"),
|
|
155
|
+
(err: unknown) => {
|
|
156
|
+
return err instanceof Error && err.message === code;
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const notPdf = createHopPdfClient({
|
|
162
|
+
baseUrl: "https://robodev.povio.dev",
|
|
163
|
+
fetch: async () =>
|
|
164
|
+
new Response("not a pdf", { status: 200, headers: { "content-type": "text/html" } }),
|
|
165
|
+
});
|
|
166
|
+
await assert.rejects(() => notPdf.fromHtml("<p>x</p>"), /pdf_failed/);
|
|
167
|
+
});
|
package/src/local-pdf.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PdfClient, PdfFromHtmlOptions, PdfPageFormat } from "@robodev-ai/sdk";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_STARBASE_URL = "https://robodev.povio.dev";
|
|
5
|
+
export const PDF_HOP_TIMEOUT_MS = 15_000;
|
|
6
|
+
export const PDF_HTML_MAX_BYTES = 512 * 1024;
|
|
7
|
+
export const PDF_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
export const PDF_DEFAULT_MARGIN_IN = 0.39;
|
|
9
|
+
export const PDF_PAGE_FORMATS = ["A4", "Letter", "Legal"] as const;
|
|
10
|
+
|
|
11
|
+
const pageFormatSchema = z.enum(PDF_PAGE_FORMATS);
|
|
12
|
+
const marginSideSchema = z.number().finite().min(0).max(2);
|
|
13
|
+
const marginBoxSchema = z.object({
|
|
14
|
+
top: marginSideSchema.optional(),
|
|
15
|
+
right: marginSideSchema.optional(),
|
|
16
|
+
bottom: marginSideSchema.optional(),
|
|
17
|
+
left: marginSideSchema.optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const pdfFromHtmlOptionsSchema = z.object({
|
|
21
|
+
format: pageFormatSchema.optional(),
|
|
22
|
+
landscape: z.boolean().optional(),
|
|
23
|
+
margin: z.union([marginSideSchema, marginBoxSchema]).optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const hopPdfBodySchema = z.object({
|
|
27
|
+
html: z.string(),
|
|
28
|
+
options: pdfFromHtmlOptionsSchema.optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export type ResolvedPdfOptions = {
|
|
32
|
+
format: PdfPageFormat;
|
|
33
|
+
landscape: boolean;
|
|
34
|
+
margin: { top: number; right: number; bottom: number; left: number };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function hopError(code: string): Error {
|
|
38
|
+
return Object.assign(new Error(code), { code });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hopStarbaseUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
42
|
+
const raw = env.STARBASE_URL;
|
|
43
|
+
if (raw === undefined) return DEFAULT_STARBASE_URL;
|
|
44
|
+
return raw.replace(/\/$/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function brokerPdfUrl(base: string): string {
|
|
48
|
+
return `${base.replace(/\/$/, "")}/internal/pdf`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolvePdfOptions(options?: PdfFromHtmlOptions): ResolvedPdfOptions {
|
|
52
|
+
const parsed = pdfFromHtmlOptionsSchema.safeParse(options ?? {});
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
throw hopError("invalid_request");
|
|
55
|
+
}
|
|
56
|
+
const margin = parsed.data.margin;
|
|
57
|
+
const sides =
|
|
58
|
+
typeof margin === "number"
|
|
59
|
+
? { top: margin, right: margin, bottom: margin, left: margin }
|
|
60
|
+
: {
|
|
61
|
+
top: margin?.top ?? PDF_DEFAULT_MARGIN_IN,
|
|
62
|
+
right: margin?.right ?? PDF_DEFAULT_MARGIN_IN,
|
|
63
|
+
bottom: margin?.bottom ?? PDF_DEFAULT_MARGIN_IN,
|
|
64
|
+
left: margin?.left ?? PDF_DEFAULT_MARGIN_IN,
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
format: parsed.data.format ?? "A4",
|
|
68
|
+
landscape: parsed.data.landscape ?? false,
|
|
69
|
+
margin: sides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parsePdfHtml(html: unknown): string {
|
|
74
|
+
if (typeof html !== "string" || !html.trim()) {
|
|
75
|
+
throw hopError("invalid_request");
|
|
76
|
+
}
|
|
77
|
+
const trimmed = html.trim();
|
|
78
|
+
if (Buffer.byteLength(trimmed, "utf8") > PDF_HTML_MAX_BYTES) {
|
|
79
|
+
throw hopError("pdf_too_large");
|
|
80
|
+
}
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isPdfBytes(bytes: Uint8Array, contentType?: string | null): boolean {
|
|
85
|
+
const type = (contentType ?? "").split(";")[0]?.trim().toLowerCase();
|
|
86
|
+
if (type === "application/pdf") return true;
|
|
87
|
+
return (
|
|
88
|
+
bytes.length >= 4 &&
|
|
89
|
+
bytes[0] === 0x25 &&
|
|
90
|
+
bytes[1] === 0x50 &&
|
|
91
|
+
bytes[2] === 0x44 &&
|
|
92
|
+
bytes[3] === 0x46
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readCappedBytes(response: Response, maxBytes: number): Promise<Buffer> {
|
|
97
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
98
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
99
|
+
throw hopError("pdf_too_large");
|
|
100
|
+
}
|
|
101
|
+
if (!response.body) {
|
|
102
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
103
|
+
if (buf.byteLength > maxBytes) throw hopError("pdf_too_large");
|
|
104
|
+
return buf;
|
|
105
|
+
}
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
const chunks: Uint8Array[] = [];
|
|
108
|
+
let total = 0;
|
|
109
|
+
while (true) {
|
|
110
|
+
const { done, value } = await reader.read();
|
|
111
|
+
if (done) break;
|
|
112
|
+
total += value.byteLength;
|
|
113
|
+
if (total > maxBytes) {
|
|
114
|
+
await reader.cancel().catch(() => undefined);
|
|
115
|
+
throw hopError("pdf_too_large");
|
|
116
|
+
}
|
|
117
|
+
chunks.push(value);
|
|
118
|
+
}
|
|
119
|
+
return Buffer.concat(chunks);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hopErrorFromBody(body: unknown): Error | null {
|
|
123
|
+
if (!body || typeof body !== "object") return null;
|
|
124
|
+
const error = (body as { error?: unknown }).error;
|
|
125
|
+
if (typeof error !== "string" || !error) return null;
|
|
126
|
+
return hopError(error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createHopPdfClient(
|
|
130
|
+
options: {
|
|
131
|
+
baseUrl?: string;
|
|
132
|
+
timeoutMs?: number;
|
|
133
|
+
fetch?: typeof fetch;
|
|
134
|
+
} = {},
|
|
135
|
+
): PdfClient {
|
|
136
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
137
|
+
const timeoutMs = options.timeoutMs ?? PDF_HOP_TIMEOUT_MS;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
async fromHtml(html, pdfOptions) {
|
|
141
|
+
const resolvedHtml = parsePdfHtml(html);
|
|
142
|
+
const resolved = resolvePdfOptions(pdfOptions);
|
|
143
|
+
const baseUrl = options.baseUrl ?? hopStarbaseUrl();
|
|
144
|
+
if (!baseUrl.trim()) {
|
|
145
|
+
throw hopError("pdf_unconfigured");
|
|
146
|
+
}
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
149
|
+
let response: Response;
|
|
150
|
+
try {
|
|
151
|
+
response = await fetchImpl(brokerPdfUrl(baseUrl), {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
html: resolvedHtml,
|
|
156
|
+
options: {
|
|
157
|
+
format: resolved.format,
|
|
158
|
+
landscape: resolved.landscape,
|
|
159
|
+
margin: resolved.margin,
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
throw hopError("pdf_failed");
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (response.status === 200) {
|
|
171
|
+
let bytes: Buffer;
|
|
172
|
+
try {
|
|
173
|
+
bytes = await readCappedBytes(response, PDF_RESPONSE_MAX_BYTES);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof Error && (error as { code?: string }).code === "pdf_too_large") {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
throw hopError("pdf_failed");
|
|
179
|
+
}
|
|
180
|
+
if (!isPdfBytes(bytes, response.headers.get("content-type"))) {
|
|
181
|
+
throw hopError("pdf_failed");
|
|
182
|
+
}
|
|
183
|
+
return bytes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let parsed: unknown = null;
|
|
187
|
+
try {
|
|
188
|
+
parsed = await response.json();
|
|
189
|
+
} catch {
|
|
190
|
+
throw hopError("pdf_failed");
|
|
191
|
+
}
|
|
192
|
+
throw hopErrorFromBody(parsed) ?? hopError("pdf_failed");
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|