@robodev-ai/runtime 0.4.1 → 0.4.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@robodev-ai/runtime",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
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`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.0"
35
+ "@robodev-ai/sdk": "0.11.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/busboy": "^1.5.4",
@@ -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("&", "&amp;")
39
+ .replaceAll("<", "&lt;")
40
+ .replaceAll(">", "&gt;")
41
+ .replaceAll('"', "&quot;")
42
+ .replaceAll("'", "&#39;");
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(/&nbsp;/gi, " ")
51
+ .replace(/&amp;/g, "&")
52
+ .replace(/&lt;/g, "<")
53
+ .replace(/&gt;/g, ">")
54
+ .replace(/&quot;/g, '"')
55
+ .replace(/&#39;/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, and the offline `robodev dev` jobs engine.
5
+ * the socket engine, local file storage, the offline `robodev dev` jobs engine,
6
+ * and email template rendering.
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,