Skip to content

fix: drop the runtime bundler from the mail send path - #56

Merged
RedStar071 merged 4 commits into
mainfrom
t3code/fix-rolldown-native-binding
Aug 20, 2026
Merged

fix: drop the runtime bundler from the mail send path#56
RedStar071 merged 4 commits into
mainfrom
t3code/fix-rolldown-native-binding

Conversation

@RedStar071

@RedStar071 RedStar071 commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Mail templates are rendered once at build time instead of on every send. packages/mail ships a checked-in artifact (src/util/compiled-templates.json) written by aube run mail:compile; sendEmail selects the variant its context matches and substitutes the values into it. @maizzle/framework moves to devDependencies.

Why

render() from Maizzle starts a Vite SSR server, so every send loaded vite and its native rolldown binding. Nitro traced both into the Vercel function, which has neither the platform binary nor a writable working directory, and the function crashed on the first message:

Error: Cannot find native binding.
    at file:///var/task/node_modules/rolldown/dist/shared/binding-Zhafd14U.mjs:597:34
  cause: Error: Cannot find module '@rolldown/binding-wasm32-wasi'

The same path was broken a second way: emailsDirectory resolved ../emails/ from import.meta.url, which after Nitro bundles the package into one chunk points at chunks/nitro/../emails/ — a directory that does not exist in the output.

Rendering is a build-time capability, so it belongs on the build side of the boundary. The registry stays in packages/mail, the compiler is a package script beside it (same shape as packages/i18n's tooling and packages/database's checked-in migrations), and the deployed app only interpolates strings. Nothing outside packages/mail changed except the docs and the new mail:compile task.

Templates that branch (v-if on an optional name or organizationName) are compiled once per combination of those fields being filled — declared as conditional in the registry — so no branch has to be taken at send time. Every other field is compiled as a placeholder, substituted HTML-escaped into the HTML part and bare into the plaintext part.

Verification

  • aube run check:repo
  • aube run lint:ci
  • aube run typecheck
  • aube test
  • aube run build

Deployment bundle, built with NITRO_PRESET=vercel aube exec turbo run build --filter=@agent-zero/dashboard and inspected at .vercel/output/functions/__fallback.func/:

before after
function size 142 MB 20 MB
packages in node_modules 307 64
rolldown, vite, @maizzle/*, esbuild present absent

The compiled markup is inlined in the server chunk (__AZ_MAIL_* placeholders present), and the bundle no longer resolves any path under emails/.

Safety and compatibility

  • I added or updated deterministic tests for changed behavior.
  • I preserved observe mode as read-only, or explained the policy change above.
  • Runtime commands and target-repository writes remain inside the runner boundary.
  • I did not expose secrets, tokens, personal data, or untrusted output in logs.
  • I updated documentation and Agent Skills when workflows or boundaries changed.

New tests in packages/mail/src/util/compiled.test.ts, all rendering through the real Maizzle pipeline:

  • the checked-in artifact equals what the templates render today, so a template edited without aube run mail:compile fails the suite;
  • a filled-in message is byte-identical to a live render of the same values;
  • substituted values are escaped: an organization name of <script>alert("x")</script> & Co reaches the recipient as text, and an accept URL keeps its & query separators inside the href.

The existing mail.test.ts suite is unchanged and still passes, including the guards on inlined styles, surviving dark-mode classes, and the optional halves of a private invitation rendering away rather than empty.

Agent context

  • Agent/tools used: Claude Code (Opus 5), one session.
  • What the agent did, and what you changed or verified yourself: the agent traced the crash to @maizzle/framework importing vite from render/createRenderer.js, confirmed it against the previously built .output/server/node_modules, wrote the compiler, the runtime substitution and the tests, and ran the full check set plus the Vercel-preset build. The before/after bundle numbers above are measured from those builds, not estimated. Every file in the diff was reviewed by hand.

Reviewer notes

  • src/util/compiled-templates.json is generated and checked in, like the Drizzle migrations: regenerate with aube run mail:compile, never edit it.
  • Escaping is deliberately stricter than the surrounding markup. Maizzle's entity transformer leaves a bare & and " where Vue wrote &amp; and &quot;; that is safe for markup Maizzle produced and unsafe for a value substituted afterwards, so escapeMailHtml re-applies the full escape. The one visible consequence is that a URL now keeps &amp; in its href where a live render had &.
  • mailTemplateIds is written out by hand rather than derived from Object.keys, so nothing has to assert a widened string[] back into the id type; a test holds the list to the registry.
  • sendEmail now throws when no compiled variant matches, which can only happen if the artifact and the registry disagree. The freshness test is what keeps that unreachable.
  • apps/mail-preview is untouched: it still runs the Maizzle CLI against the same emails/ sources through its symlink.
  • The branch carries one merge of origin/main; the only conflict was pnpm-lock.yaml, resolved by taking main's lockfile and reinstalling, so its only difference from main is the packages/mail dependency move.

Note

Replace runtime Maizzle rendering with precompiled template variants in sendEmail

  • Adds a build-time script compile-templates.ts that renders all Maizzle templates into a checked-in JSON artifact (compiled-templates.json), pre-generating every variant of conditional fields
  • sendEmail in mail.ts now selects the matching precompiled variant by conditional-field key, then interpolates escaped values into HTML and raw values into plaintext, throwing if the variant is missing
  • Extends the template registry in templates.ts with per-field fields metadata (interpolated vs conditional) to drive variant selection and compilation
  • Adds mail:compile scripts at the root and package level, wired into Turbo without caching, plus a mail scope in the semantic PR validator
  • Behavioral Change: sendEmail throws if the expected compiled variant is absent in compiled-templates.json; templates must be recompiled via aube run mail:compile whenever template markup or conditional-field declarations change

Macroscope summarized 70212a3.

- Move Maizzle from runtime rendering to build-time compilation via scripts/compile-templates.ts
- Compiled markup and plaintext variants are checked in as compiled-templates.json
- sendEmail() now selects variant and substitutes values instead of calling render()
- Necessary for serverless deployments that lack bundler and platform-native binary
- Move @maizzle/* to devDependencies since compilation is now build-only
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-zero-dashboard Ready Ready Preview Aug 20, 2026 12:01pm
agent-zero-docs Ready Ready Preview Aug 20, 2026 12:01pm
agent-zero-marketing Ready Ready Preview Aug 20, 2026 12:01pm

@macroscopeapp

macroscopeapp Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR fundamentally changes mail sending from runtime Maizzle rendering to build-time compilation with runtime substitution. While well-tested, the architectural change introduces new interpolation/escaping logic and variant selection that warrants human review.

You can add or adjust custom eligibility rules. Learn more.

Maizzle's `render()` starts a Vite SSR server, so every send loaded
`vite` and its native rolldown binding. Nitro traced both into the
Vercel function, where the platform binary is absent and the filesystem
is read-only, and the function crashed on the first message with
"Cannot find native binding". The path the templates were read from
also no longer existed once Nitro bundled the package into one chunk.

Templates are now rendered once at build time (`aube run mail:compile`)
into a checked-in artifact the package bundles: one variant per
combination of the conditional fields the registry declares, with every
other field left as a placeholder. `sendEmail` picks the variant its
context matches and substitutes the values, escaping them for the HTML
part and leaving them bare for the plaintext one.

Maizzle moves to devDependencies, which drops the bundler, esbuild and
tailwind out of the deployed function: 142 MB and 307 packages down to
20 MB and 64.
@RedStar071 RedStar071 changed the title Pull Request: Remove device auth and hosted infra, precompile mail templates fix(mail): drop the runtime bundler from the send path Aug 20, 2026
@RedStar071 RedStar071 changed the title fix(mail): drop the runtime bundler from the send path fix: drop the runtime bundler from the mail send path Aug 20, 2026
`packages/mail` is a workspace package like any other, but its name was
missing from the scope list the semantic-pull-request check enforces, so
a title naming the package it changes was rejected.
@RedStar071 RedStar071 changed the title fix: drop the runtime bundler from the mail send path fix(mail): drop the runtime bundler from the send path Aug 20, 2026
@RedStar071 RedStar071 changed the title fix(mail): drop the runtime bundler from the send path fix: drop the runtime bundler from the mail send path Aug 20, 2026
@RedStar071
RedStar071 merged commit 2b2f076 into main Aug 20, 2026
20 of 22 checks passed
@RedStar071
RedStar071 deleted the t3code/fix-rolldown-native-binding branch August 20, 2026 12:00
RedStar071 pushed a commit that referenced this pull request Aug 20, 2026
Picks up #56. The one conflict is the build-time-capture callout on the
environment-variables page, where main edited text inside a block this
branch had already converted to MDC; main's wording wins, in the MDC
form. The two `BETTER_AUTH_*` containers main added on the same page are
converted the same way, so the page carries no VitePress syntax.

Verified by rebuilding the docs app: prerender succeeds, both restored
callouts render, and all @include directives still resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012SK7GND614AB6qpZ51aPFx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant