@robodev-ai/runtime 0.4.2 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@robodev-ai/runtime",
3
- "version": "0.4.2",
4
- "description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine, and local file storage. Shared by hosted Starbase and `robodev dev`.",
3
+ "version": "0.5.1",
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.11.1"
35
+ "@robodev-ai/sdk": "0.13.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
- * and email template rendering.
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,
@@ -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: {
@@ -420,6 +421,24 @@ test("missing handler is route_not_found", async () => {
420
421
  await waitUntil(() => store.jobs[0]?.last_error === "route_not_found");
421
422
  });
422
423
 
424
+ test("handler receives clients.storage", async () => {
425
+ const store = memoryJobs();
426
+ const clients = mockClients();
427
+ const seen: unknown[] = [];
428
+ const engine = createLocalJobsEngine({
429
+ query: store.query,
430
+ getGeneration: () => ({
431
+ db: {} as RobodevDb,
432
+ jobs: [{ name: "digest", def: jobDef((ctx) => seen.push(ctx.storage)) }],
433
+ }),
434
+ clients: () => clients,
435
+ });
436
+ await engine.enqueue({ name: "digest" });
437
+ await engine.tickJobs();
438
+ await waitUntil(() => store.jobs[0]?.status === "succeeded");
439
+ assert.equal(seen[0], clients.storage);
440
+ });
441
+
423
442
  test("hosted-only clients still throw in job handlers; email is real", async () => {
424
443
  const store = memoryJobs();
425
444
  const seen: string[] = [];
package/src/local-jobs.ts CHANGED
@@ -304,6 +304,8 @@ export function createLocalJobsEngine(options: LocalJobsEngineOptions): LocalJob
304
304
  push: clients.push,
305
305
  jobs: clients.jobs,
306
306
  sockets: clients.sockets,
307
+ pdf: clients.pdf,
308
+ storage: clients.storage,
307
309
  payload,
308
310
  });
309
311
  }, clampTimeoutMs(job.def.timeoutMs));
@@ -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
+ });
@@ -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
+ }