@robodev-ai/runtime 0.4.1 → 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/email-templates.test.ts +105 -0
- package/src/email-templates.ts +169 -0
- package/src/index.ts +48 -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 },
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
escapeEmailHtml,
|
|
8
|
+
extractTemplateVars,
|
|
9
|
+
htmlToPlainText,
|
|
10
|
+
isCustomEmailSlug,
|
|
11
|
+
loadLocalEmailTemplate,
|
|
12
|
+
mergeCustomTemplateVariables,
|
|
13
|
+
renderEmailFields,
|
|
14
|
+
renderEmailTemplate,
|
|
15
|
+
resolveEmailSendInput,
|
|
16
|
+
} from "./email-templates.js";
|
|
17
|
+
|
|
18
|
+
test("renderEmailTemplate replaces whitespace-tolerant vars", () => {
|
|
19
|
+
assert.equal(renderEmailTemplate("Hi {{ name }}", { name: "Ada" }), "Hi Ada");
|
|
20
|
+
assert.equal(renderEmailTemplate("Hi {{name}}", { name: "Ada" }), "Hi Ada");
|
|
21
|
+
assert.equal(renderEmailTemplate("Hi {{ missing }}", {}), "Hi ");
|
|
22
|
+
assert.equal(renderEmailTemplate("Hi {{1bad}}", { "1bad": "x" }), "Hi {{1bad}}");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("renderEmailTemplate HTML-escapes only in html mode", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
renderEmailTemplate("<p>{{name}}</p>", { name: `<a>"'` }, { html: true }),
|
|
28
|
+
`<p>${escapeEmailHtml(`<a>"'`)}</p>`,
|
|
29
|
+
);
|
|
30
|
+
assert.equal(renderEmailTemplate("{{name}}", { name: "<x>" }), "<x>");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("extractTemplateVars unions unique names in order", () => {
|
|
34
|
+
assert.deepEqual(extractTemplateVars("{{email}} {{name}}", "{{email}} {{link}}"), [
|
|
35
|
+
"email",
|
|
36
|
+
"name",
|
|
37
|
+
"link",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("htmlToPlainText strips tags", () => {
|
|
42
|
+
assert.equal(htmlToPlainText("<p>Your reset code is <code>123</code>.</p>"), "Your reset code is 123 .");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("resolveEmailSendInput renders catalog then applies overrides", () => {
|
|
46
|
+
const resolved = resolveEmailSendInput(
|
|
47
|
+
{
|
|
48
|
+
to: "a@b.com",
|
|
49
|
+
template: "welcome",
|
|
50
|
+
vars: { name: "Ada" },
|
|
51
|
+
subject: "Override",
|
|
52
|
+
},
|
|
53
|
+
{ subject: "Hi {{name}}", html: "<p>Hello {{name}}</p>", text: "Hello {{name}}" },
|
|
54
|
+
);
|
|
55
|
+
assert.equal(resolved.subject, "Override");
|
|
56
|
+
assert.equal(resolved.html, "<p>Hello Ada</p>");
|
|
57
|
+
assert.equal(resolved.text, "Hello Ada");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("resolveEmailSendInput throws template_not_found without catalog or body", () => {
|
|
61
|
+
assert.throws(
|
|
62
|
+
() => resolveEmailSendInput({ to: "a@b.com", template: "missing" }, null),
|
|
63
|
+
(error: unknown) => error instanceof Error && error.message === "template_not_found",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("loadLocalEmailTemplate reads html plus optional json", async () => {
|
|
68
|
+
const root = await mkdtemp(join(tmpdir(), "robodev-emails-"));
|
|
69
|
+
await mkdir(join(root, "robodev", "emails"), { recursive: true });
|
|
70
|
+
await writeFile(join(root, "robodev", "emails", "welcome.html"), "<p>Hi {{name}}</p>", "utf8");
|
|
71
|
+
await writeFile(
|
|
72
|
+
join(root, "robodev", "emails", "welcome.json"),
|
|
73
|
+
JSON.stringify({ subject: "Hello", text: "Hi {{name}}" }),
|
|
74
|
+
"utf8",
|
|
75
|
+
);
|
|
76
|
+
const loaded = await loadLocalEmailTemplate(root, "welcome");
|
|
77
|
+
assert.deepEqual(loaded, {
|
|
78
|
+
slug: "welcome",
|
|
79
|
+
subject: "Hello",
|
|
80
|
+
html: "<p>Hi {{name}}</p>",
|
|
81
|
+
text: "Hi {{name}}",
|
|
82
|
+
});
|
|
83
|
+
assert.equal(await loadLocalEmailTemplate(root, "missing"), null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("custom slug and variable merge rules", () => {
|
|
87
|
+
assert.equal(isCustomEmailSlug("welcome"), true);
|
|
88
|
+
assert.equal(isCustomEmailSlug("auth.welcome"), false);
|
|
89
|
+
assert.equal(isCustomEmailSlug("Welcome"), false);
|
|
90
|
+
assert.deepEqual(mergeCustomTemplateVariables(["email", "name"], ["extra", "email", "1bad"]), [
|
|
91
|
+
"email",
|
|
92
|
+
"name",
|
|
93
|
+
"extra",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("renderEmailFields derives plaintext when text is empty", () => {
|
|
98
|
+
const rendered = renderEmailFields(
|
|
99
|
+
{ subject: "Hi {{name}}", html: "<p>Hello {{name}}</p>", text: "" },
|
|
100
|
+
{ name: "Ada" },
|
|
101
|
+
);
|
|
102
|
+
assert.equal(rendered.subject, "Hi Ada");
|
|
103
|
+
assert.equal(rendered.html, "<p>Hello Ada</p>");
|
|
104
|
+
assert.equal(rendered.text, "Hello Ada");
|
|
105
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { EmailSendInput } from "@robodev-ai/sdk";
|
|
4
|
+
|
|
5
|
+
export const EMAIL_TEMPLATE_VAR_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
6
|
+
export const EMAIL_TEMPLATE_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
export const EMAIL_TEMPLATE_MAX_VARS = 32;
|
|
8
|
+
export const EMAIL_TEMPLATE_MAX_VAR_NAME = 40;
|
|
9
|
+
export const EMAIL_TEMPLATE_MAX_BYTES = 200 * 1024;
|
|
10
|
+
export const EMAIL_TEMPLATE_MAX_SUBJECT = 200;
|
|
11
|
+
export const EMAIL_TEMPLATE_MAX_CUSTOM = 40;
|
|
12
|
+
export const CUSTOM_EMAIL_SLUG_RE = /^[a-z][a-z0-9-]{0,62}$/;
|
|
13
|
+
|
|
14
|
+
export const SYSTEM_EMAIL_SLUGS = ["auth.welcome", "auth.magic-link", "auth.forgot-password"] as const;
|
|
15
|
+
export type SystemEmailSlug = (typeof SYSTEM_EMAIL_SLUGS)[number];
|
|
16
|
+
|
|
17
|
+
export type EmailTemplateKind = "system" | "custom";
|
|
18
|
+
|
|
19
|
+
export type LocalEmailTemplate = {
|
|
20
|
+
slug: string;
|
|
21
|
+
subject: string;
|
|
22
|
+
html: string;
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type EmailTemplateVars = Record<string, string | number | boolean>;
|
|
27
|
+
|
|
28
|
+
export function isSystemEmailSlug(slug: string): slug is SystemEmailSlug {
|
|
29
|
+
return (SYSTEM_EMAIL_SLUGS as readonly string[]).includes(slug);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isCustomEmailSlug(slug: string): boolean {
|
|
33
|
+
return CUSTOM_EMAIL_SLUG_RE.test(slug) && !slug.startsWith("auth.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function escapeEmailHtml(value: string): string {
|
|
37
|
+
return value
|
|
38
|
+
.replaceAll("&", "&")
|
|
39
|
+
.replaceAll("<", "<")
|
|
40
|
+
.replaceAll(">", ">")
|
|
41
|
+
.replaceAll('"', """)
|
|
42
|
+
.replaceAll("'", "'");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function htmlToPlainText(html: string): string {
|
|
46
|
+
return html
|
|
47
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ")
|
|
48
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ")
|
|
49
|
+
.replace(/<[^>]+>/g, " ")
|
|
50
|
+
.replace(/ /gi, " ")
|
|
51
|
+
.replace(/&/g, "&")
|
|
52
|
+
.replace(/</g, "<")
|
|
53
|
+
.replace(/>/g, ">")
|
|
54
|
+
.replace(/"/g, '"')
|
|
55
|
+
.replace(/'/g, "'")
|
|
56
|
+
.replace(/\s+/g, " ")
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function extractTemplateVars(...parts: Array<string | null | undefined>): string[] {
|
|
61
|
+
const names: string[] = [];
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
if (!part) continue;
|
|
65
|
+
const re = new RegExp(EMAIL_TEMPLATE_VAR_RE.source, "g");
|
|
66
|
+
for (const match of part.matchAll(re)) {
|
|
67
|
+
const name = match[1] ?? "";
|
|
68
|
+
if (!name || seen.has(name)) continue;
|
|
69
|
+
seen.add(name);
|
|
70
|
+
names.push(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return names;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderEmailTemplate(
|
|
77
|
+
template: string,
|
|
78
|
+
vars: EmailTemplateVars = {},
|
|
79
|
+
options: { html?: boolean } = {},
|
|
80
|
+
): string {
|
|
81
|
+
return template.replace(EMAIL_TEMPLATE_VAR_RE, (token, name: string) => {
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(vars, name)) return "";
|
|
83
|
+
const raw = vars[name];
|
|
84
|
+
const value = raw == null ? "" : String(raw);
|
|
85
|
+
return options.html ? escapeEmailHtml(value) : value;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderEmailFields(
|
|
90
|
+
input: { subject: string; html: string; text?: string },
|
|
91
|
+
vars: EmailTemplateVars = {},
|
|
92
|
+
): { subject: string; html: string; text: string } {
|
|
93
|
+
const html = renderEmailTemplate(input.html, vars, { html: true });
|
|
94
|
+
const textSource = input.text?.trim()
|
|
95
|
+
? renderEmailTemplate(input.text, vars)
|
|
96
|
+
: htmlToPlainText(html);
|
|
97
|
+
return {
|
|
98
|
+
subject: renderEmailTemplate(input.subject, vars),
|
|
99
|
+
html,
|
|
100
|
+
text: textSource,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function localEmailTemplatePaths(slug: string): { html: string; json: string } {
|
|
105
|
+
return {
|
|
106
|
+
html: `robodev/emails/${slug}.html`,
|
|
107
|
+
json: `robodev/emails/${slug}.json`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function loadLocalEmailTemplate(
|
|
112
|
+
projectRoot: string,
|
|
113
|
+
slug: string,
|
|
114
|
+
): Promise<LocalEmailTemplate | null> {
|
|
115
|
+
const paths = localEmailTemplatePaths(slug);
|
|
116
|
+
let html: string;
|
|
117
|
+
try {
|
|
118
|
+
html = await readFile(join(projectRoot, paths.html), "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
let subject = "";
|
|
123
|
+
let text = "";
|
|
124
|
+
try {
|
|
125
|
+
const raw = await readFile(join(projectRoot, paths.json), "utf8");
|
|
126
|
+
const parsed = JSON.parse(raw) as { subject?: unknown; text?: unknown };
|
|
127
|
+
if (typeof parsed.subject === "string") subject = parsed.subject;
|
|
128
|
+
if (typeof parsed.text === "string") text = parsed.text;
|
|
129
|
+
} catch {
|
|
130
|
+
/* html-only catalog row */
|
|
131
|
+
}
|
|
132
|
+
return { slug, subject, html, text };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveEmailSendInput(
|
|
136
|
+
input: EmailSendInput,
|
|
137
|
+
catalog: { subject: string; html: string; text?: string } | null,
|
|
138
|
+
): EmailSendInput {
|
|
139
|
+
if (!input.template) return input;
|
|
140
|
+
if (!catalog) {
|
|
141
|
+
if (input.html || input.text) return input;
|
|
142
|
+
throw Object.assign(new Error("template_not_found"), { code: "template_not_found" });
|
|
143
|
+
}
|
|
144
|
+
const rendered = renderEmailFields(catalog, input.vars ?? {});
|
|
145
|
+
return {
|
|
146
|
+
...input,
|
|
147
|
+
subject: input.subject ?? rendered.subject,
|
|
148
|
+
html: input.html ?? rendered.html,
|
|
149
|
+
text: input.text ?? rendered.text,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function mergeCustomTemplateVariables(
|
|
154
|
+
extracted: readonly string[],
|
|
155
|
+
extras: readonly string[] = [],
|
|
156
|
+
): string[] {
|
|
157
|
+
const names: string[] = [];
|
|
158
|
+
const seen = new Set<string>();
|
|
159
|
+
for (const name of [...extracted, ...extras]) {
|
|
160
|
+
if (!EMAIL_TEMPLATE_VAR_NAME_RE.test(name) || name.length > EMAIL_TEMPLATE_MAX_VAR_NAME) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (seen.has(name)) continue;
|
|
164
|
+
seen.add(name);
|
|
165
|
+
names.push(name);
|
|
166
|
+
if (names.length >= EMAIL_TEMPLATE_MAX_VARS) break;
|
|
167
|
+
}
|
|
168
|
+
return names;
|
|
169
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* `@robodev-ai/runtime` — the project runtime shared by hosted Starbase deploys and the
|
|
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
|
-
* the socket engine, local file storage,
|
|
5
|
+
* the socket engine, local file storage, the offline `robodev dev` jobs engine,
|
|
6
|
+
* email template rendering, and the offline PDF hop client.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export {
|
|
@@ -279,6 +280,33 @@ export {
|
|
|
279
280
|
type StorageQueryable,
|
|
280
281
|
} from "./local-storage.js";
|
|
281
282
|
|
|
283
|
+
export {
|
|
284
|
+
CUSTOM_EMAIL_SLUG_RE,
|
|
285
|
+
EMAIL_TEMPLATE_MAX_BYTES,
|
|
286
|
+
EMAIL_TEMPLATE_MAX_CUSTOM,
|
|
287
|
+
EMAIL_TEMPLATE_MAX_SUBJECT,
|
|
288
|
+
EMAIL_TEMPLATE_MAX_VAR_NAME,
|
|
289
|
+
EMAIL_TEMPLATE_MAX_VARS,
|
|
290
|
+
EMAIL_TEMPLATE_VAR_NAME_RE,
|
|
291
|
+
EMAIL_TEMPLATE_VAR_RE,
|
|
292
|
+
SYSTEM_EMAIL_SLUGS,
|
|
293
|
+
escapeEmailHtml,
|
|
294
|
+
extractTemplateVars,
|
|
295
|
+
htmlToPlainText,
|
|
296
|
+
isCustomEmailSlug,
|
|
297
|
+
isSystemEmailSlug,
|
|
298
|
+
loadLocalEmailTemplate,
|
|
299
|
+
localEmailTemplatePaths,
|
|
300
|
+
mergeCustomTemplateVariables,
|
|
301
|
+
renderEmailFields,
|
|
302
|
+
renderEmailTemplate,
|
|
303
|
+
resolveEmailSendInput,
|
|
304
|
+
type EmailTemplateKind,
|
|
305
|
+
type EmailTemplateVars,
|
|
306
|
+
type LocalEmailTemplate,
|
|
307
|
+
type SystemEmailSlug,
|
|
308
|
+
} from "./email-templates.js";
|
|
309
|
+
|
|
282
310
|
export {
|
|
283
311
|
LOCAL_DEV_IDENTITY_AUD,
|
|
284
312
|
LOCAL_DEV_IDENTITY_TYP,
|
|
@@ -295,3 +323,22 @@ export {
|
|
|
295
323
|
verifyLocalDevIdentityAssertion,
|
|
296
324
|
verifyLocalGoogleState,
|
|
297
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
|
+
}
|