diff --git a/apps/backend/package.json b/apps/backend/package.json index ba59f109d3..185a332aa2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -77,7 +77,7 @@ "@upstash/qstash": "^2.8.2", "@vercel/functions": "^2.0.0", "@vercel/otel": "^1.10.4", - "@vercel/sandbox": "^1.1.2", + "@vercel/sandbox": "^1.2.0", "ai": "^4.3.17", "bcrypt": "^5.1.1", "chokidar-cli": "^3.0.0", diff --git a/apps/backend/src/lib/email-rendering.test.tsx b/apps/backend/src/lib/email-rendering.test.tsx index 66d44bed78..b7e055c413 100644 --- a/apps/backend/src/lib/email-rendering.test.tsx +++ b/apps/backend/src/lib/email-rendering.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { renderEmailsForTenancyBatched, type RenderEmailRequestForTenancy } from './email-rendering'; +import { renderEmailsForTenancyBatched, renderEmailWithTemplate, type RenderEmailRequestForTenancy } from './email-rendering'; describe('renderEmailsForTenancyBatched', () => { const createSimpleTemplateSource = (content: string) => ` @@ -313,7 +313,7 @@ describe('renderEmailsForTenancyBatched', () => { }); describe('error handling', () => { - it('should return error for invalid template syntax', async () => { + it('bundling error: invalid syntax', async () => { const request = createMockRequest(1, { templateSource: 'invalid syntax {{{ not jsx', }); @@ -326,9 +326,13 @@ describe('renderEmailsForTenancyBatched', () => { } }); - it('should return error for invalid theme syntax', async () => { + it('bundling error: missing required export', async () => { const request = createMockRequest(1, { - themeSource: 'export function EmailTheme( { unclosed bracket', + templateSource: ` + export function WrongName() { + return
Wrong function name
; + } + `, }); const result = await renderEmailsForTenancyBatched([request]); @@ -338,12 +342,12 @@ describe('renderEmailsForTenancyBatched', () => { } }); - it('should return error when template does not export EmailTemplate', async () => { + it('runtime error: component throws (returns JSON with message and stack)', async () => { const request = createMockRequest(1, { templateSource: ` export const variablesSchema = (v: any) => v; - export function WrongName() { - return
Wrong function name
; + export function EmailTemplate() { + throw new Error('Template render failed'); } `, }); @@ -351,23 +355,55 @@ describe('renderEmailsForTenancyBatched', () => { expect(result.status).toBe('error'); if (result.status === 'error') { - expect(result.error).toBeDefined(); + expect(result.error).toContain('Template render failed'); + // Verify error is JSON with stack trace + const parsed = JSON.parse(result.error); + expect(parsed.message).toContain('Template render failed'); + expect(parsed.stack).toBeDefined(); } }); - it('should return error when theme does not export EmailTheme', async () => { + it('runtime error: arktype validation fails', async () => { const request = createMockRequest(1, { - themeSource: ` - export function WrongThemeName({ children }: any) { - return
{children}
; + templateSource: ` + import { type } from "arktype"; + export const variablesSchema = type({ requiredField: "string" }); + export function EmailTemplate({ variables }: any) { + return
{variables.requiredField}
; } `, + input: { + user: { displayName: 'User 1' }, + project: { displayName: 'Project 1' }, + variables: { wrongField: 'value' }, + }, }); const result = await renderEmailsForTenancyBatched([request]); expect(result.status).toBe('error'); if (result.status === 'error') { - expect(result.error).toBeDefined(); + expect(result.error).toContain('requiredField'); + } + }); + + it('batch behavior: single failure fails entire batch', async () => { + const requests = [ + createMockRequest(1), + createMockRequest(2, { + templateSource: ` + export const variablesSchema = (v: any) => v; + export function EmailTemplate() { + throw new Error('Second template error'); + } + `, + }), + createMockRequest(3), + ]; + const result = await renderEmailsForTenancyBatched(requests); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error).toContain('Second template error'); } }); }); @@ -464,3 +500,55 @@ describe('renderEmailsForTenancyBatched', () => { }, 30000); // Extended timeout for large batch }); }); + +describe('renderEmailWithTemplate', () => { + const simpleTemplate = ` + export const variablesSchema = (v: any) => v; + export function EmailTemplate({ user, project }: any) { + return ( +
+ {user.displayName} + {project.displayName} +
+ ); + } + `; + + const simpleTheme = ` + export function EmailTheme({ children }: any) { + return
{children}
; + } + `; + + it('preview mode: uses default user and project when not provided', async () => { + const result = await renderEmailWithTemplate(simpleTemplate, simpleTheme, { + previewMode: true, + }); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.data.html).toContain('John Doe'); + expect(result.data.html).toContain('My Project'); + } + }); + + it('preview mode: merges PreviewVariables from template', async () => { + const templateWithPreviewVars = ` + import { type } from "arktype"; + export const variablesSchema = type({ greeting: "string" }); + export function EmailTemplate({ variables }: any) { + return
{variables.greeting}
; + } + EmailTemplate.PreviewVariables = { greeting: "Hello from preview!" }; + `; + + const result = await renderEmailWithTemplate(templateWithPreviewVars, simpleTheme, { + previewMode: true, + }); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.data.html).toContain('Hello from preview!'); + } + }); +}); diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index 801858ec2f..0fd2529b6f 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -61,7 +61,10 @@ const entryJs = deindent` const result = await renderAll(); return { status: "ok", data: result }; } catch (e) { - return { status: "error", error: String(e) }; + if (e instanceof Error) { + return { status: "error", error: { message: e.message, stack: e.stack, cause: e.cause } }; + } + return { status: "error", error: { message: String(e), stack: undefined, cause: undefined } }; } }; `; @@ -69,7 +72,7 @@ const entryJs = deindent` type EmailRenderResult = { html: string, text: string, subject?: string, notificationCategory?: string }; type ExecuteResult = | { status: "ok", data: unknown } - | { status: "error", error: string }; + | { status: "error", error: unknown }; async function bundleAndExecute( files: Record & { '/entry.js': string } @@ -87,7 +90,7 @@ async function bundleAndExecute( if (["development", "test"].includes(getNodeEnvironment())) { const executeResult = await executeJavascript(bundle.data, { nodeModules, engine: 'freestyle' }) as ExecuteResult; if (executeResult.status === "error") { - return Result.error(executeResult.error); + return Result.error(JSON.stringify(executeResult.error)); } return Result.ok(executeResult.data as T); } @@ -97,11 +100,23 @@ async function bundleAndExecute( if (executeResult.status === "error") { const vercelResult = await executeJavascript(bundle.data, { nodeModules, engine: 'vercel-sandbox' }) as ExecuteResult; if (vercelResult.status === "error") { - return Result.error(executeResult.error); + captureError("email-rendering-freestyle-and-vercel-runtime-error", new StackAssertionError( + "Email rendering failed with both freestyle and vercel-sandbox engines", + { + freestyleError: executeResult.error, + vercelError: vercelResult.error, + innerCode: bundle.data, + innerOptions: [ + { nodeModules, engine: 'freestyle' }, + { nodeModules, engine: 'vercel-sandbox' }, + ], + }, + )); + return Result.error(JSON.stringify(vercelResult.error)); } captureError("email-rendering-freestyle-runtime-error", new StackAssertionError( "Email rendering failed with freestyle but succeeded with vercel-sandbox", - { freestyleError: executeResult.error } + { freestyleError: executeResult.error, innerCode: bundle.data, innerOptions: { nodeModules, engine: 'freestyle' } } )); return Result.ok(vercelResult.data as T); } diff --git a/apps/backend/src/lib/js-execution.tsx b/apps/backend/src/lib/js-execution.tsx index 30dc0bf770..97cab57b44 100644 --- a/apps/backend/src/lib/js-execution.tsx +++ b/apps/backend/src/lib/js-execution.tsx @@ -42,7 +42,7 @@ function createFreestyleEngine(): JsEngine { }); if (response.result === undefined) { - throw new StackAssertionError("Freestyle execution returned undefined result", { response }); + throw new StackAssertionError("Freestyle execution returned undefined result", { response, innerCode: code, innerOptions: options }); } return response.result; @@ -75,7 +75,7 @@ function createVercelSandboxEngine(): JsEngine { const installResult = await sandbox.runCommand('npm', ['install', '--no-save', ...packages]); if (installResult.exitCode !== 0) { - throw new StackAssertionError("Failed to install packages in Vercel Sandbox", { exitCode: installResult.exitCode }); + throw new StackAssertionError("Failed to install packages in Vercel Sandbox", { exitCode: installResult.exitCode, innerCode: code, innerOptions: options }); } } @@ -85,7 +85,7 @@ function createVercelSandboxEngine(): JsEngine { import { writeFileSync } from 'fs'; import fn from './code.mjs'; const result = await fn(); - writeFileSync('${resultPath}', JSON.stringify(result)); + writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify(result)); `; await sandbox.writeFiles([ @@ -96,29 +96,19 @@ function createVercelSandboxEngine(): JsEngine { const runResult = await sandbox.runCommand('node', ['/vercel/sandbox/runner.mjs']); if (runResult.exitCode !== 0) { - throw new StackAssertionError("Vercel Sandbox execution failed", { exitCode: runResult.exitCode }); + throw new StackAssertionError("Vercel Sandbox runner exited with non-zero code", { innerCode: code, innerOptions: options, exitCode: runResult.exitCode }); } - // Read the result file by catting it to stdout - let resultJson = ''; - const { Writable } = await import('stream'); - const stdoutStream = new Writable({ - write(chunk, _encoding, callback) { - resultJson += chunk.toString(); - callback(); - }, - }); - - const catResult = await sandbox.runCommand({ cmd: 'cat', args: [resultPath], stdout: stdoutStream }); - - if (catResult.exitCode !== 0) { - throw new StackAssertionError("Failed to read result file from Vercel Sandbox", { exitCode: catResult.exitCode }); + const resultBuffer = await sandbox.readFileToBuffer({ path: resultPath }); + if (resultBuffer === null) { + throw new StackAssertionError("Result file not found in Vercel Sandbox", { resultPath, innerCode: code, innerOptions: options }); } + const resultJson = resultBuffer.toString(); try { return JSON.parse(resultJson); - } catch (e) { - throw new StackAssertionError("Failed to parse result from Vercel Sandbox", { resultJson, cause: e }); + } catch (e: any) { + throw new StackAssertionError("Failed to parse result from Vercel Sandbox", { resultJson, cause: e, innerCode: code, innerOptions: options }); } } finally { await sandbox.stop(); @@ -134,6 +124,11 @@ const engineMap = new Map([ const engines: JsEngine[] = Array.from(engineMap.values()); +/** + * Executes the given code with the given options. Returns the result of the code execution + * if it is JSON-serializable. Has undefined behavior if it is not JSON-serializable or if + * the code throws an error. + */ export async function executeJavascript(code: string, options: ExecuteJavascriptOptions = {}): Promise { return await traceSpan({ description: 'js-execution.executeJavascript', @@ -178,7 +173,7 @@ async function runSanityTest(code: string, options: ExecuteJavascriptOptions) { if (failures.length > 0) { captureError("js-execution-sanity-test-failures", new StackAssertionError( `JS execution sanity test: ${failures.length} engine(s) failed`, - { failures, successfulEngines: results.map(r => r.engine) } + { failures, successfulEngines: results.map(r => r.engine), innerCode: code, innerOptions: options } )); } @@ -191,7 +186,7 @@ async function runSanityTest(code: string, options: ExecuteJavascriptOptions) { if (!allEqual) { captureError("js-execution-sanity-test-mismatch", new StackAssertionError( "JS execution sanity test: engines returned different results", - { results } + { results, innerCode: code, innerOptions: options } )); } } @@ -228,7 +223,7 @@ async function runWithFallback(code: string, options: ExecuteJavascriptOptions): if (i < engines.length - 1) { captureError(`js-execution-${engine.name}-failed`, new StackAssertionError( `JS execution engine '${engine.name}' failed, falling back to next engine`, - { error: engineError, attempts: retryResult.attempts } + { error: engineError, attempts: retryResult.attempts, innerCode: code, innerOptions: options } )); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b533bc9fb3..7ad38e6779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,8 +196,8 @@ importers: specifier: ^1.10.4 version: 1.10.4(@opentelemetry/api-logs@0.53.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0)) '@vercel/sandbox': - specifier: ^1.1.2 - version: 1.1.9 + specifier: ^1.2.0 + version: 1.2.0 ai: specifier: ^4.3.17 version: 4.3.17(react@19.2.3)(zod@3.25.76) @@ -9157,8 +9157,8 @@ packages: '@opentelemetry/sdk-metrics': ^1.19.0 '@opentelemetry/sdk-trace-base': ^1.19.0 - '@vercel/sandbox@1.1.9': - resolution: {integrity: sha512-DbCYoA8GVMRshFtXNpSPX+7xqgCoUgk0Zm+F0B22UgvtvmAvxpxtPp9t8pfW56AUez3UXhvdJNIaO16A9G6AkQ==} + '@vercel/sandbox@1.2.0': + resolution: {integrity: sha512-oq0d2xQuiDq8LyoZOZW+i+QmNkMQ0/ddEGHMukBsF+cxX7ohBEXOV/a0+SopBwEsKCjq9j55QFP56G3MU/iEjA==} '@vercel/speed-insights@1.0.12': resolution: {integrity: sha512-ZGQ+a7bcfWJD2VYEp2R1LHvRAMyyaFBYytZXsfnbOMkeOvzGNVxUL7aVUvisIrTZjXTSsxG45DKX7yiw6nq2Jw==} @@ -25418,7 +25418,7 @@ snapshots: '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) - '@vercel/sandbox@1.1.9': + '@vercel/sandbox@1.2.0': dependencies: '@vercel/oidc': 3.1.0 async-retry: 1.3.3