Zum Hauptinhalt springen
Astro JS: Experimentelle Container API auf Cloudflare Workers
#Astro #Cloudflare Workers #Container API #SSR #Edge Computing

Astro JS: Experimentelle Container API auf Cloudflare Workers


Wie ein Build-Hook die Astro Container API auf Edge-Runtimes zum Laufen bringt

SerieAHA-Stack
Teil 1 von 3
10 Minuten Lesezeit

Astros Container API ist eines der spannendsten experimentellen Features des Frameworks. Sie erlaubt es, Astro-Komponenten programmatisch zu rendern – außerhalb des normalen Request-Lifecycles. Das eröffnet interessante Möglichkeiten: dynamische E-Mail-Templates, Komponenten in API-Endpoints, serverseitiges Rendering on-demand.

Nur: Auf Cloudflare Workers funktioniert das nicht out-of-the-box. Die Container API nutzt intern file:// URLs – und die gibt es in Edge-Runtimes schlicht nicht.

Dieser Artikel erklärt das Problem, zeigt die Lösung und demonstriert beides an einem funktionierenden Beispielprojekt.

Was ist die Astro Container API?

Die Container API (derzeit experimental_AstroContainer) ermöglicht es, Astro-Komponenten isoliert zu rendern:

import { experimental_AstroContainer } from 'astro/container';
import MyComponent from '@components/MyComponent.astro';

const container = await experimental_AstroContainer.create();
const html = await container.renderToString(MyComponent, {
  props: { title: 'Dynamischer Titel' }
});

Typische Use Cases:

  • E-Mail-Templates: Astro-Komponenten für transaktionale E-Mails nutzen
  • API-Endpoints: HTML-Fragmente für HTMX oder Partial Responses
  • Testing: Komponenten isoliert testen
  • Dokumentation: Komponenten-Previews generieren

Warum Container API statt Alternativen?

Bevor wir zum Problem und der Lösung kommen: Warum überhaupt die Container API nutzen? Es gibt schließlich Alternativen.

Alternative 1: Template-Strings

Der naheliegendste Ansatz – HTML als String zusammenbauen:

// src/pages/api/tasks.ts
export const POST: APIRoute = async ({ request }) => {
  const task = await createTask(request);
  
  const html = `
    <div class="task" data-id="${task.id}">
      <span class="task-text">${escapeHtml(task.text)}</span>
      <button hx-delete="/api/tasks/${task.id}">Löschen</button>
    </div>
  `;
  
  return new Response(html, {
    headers: { 'Content-Type': 'text/html' }
  });
};

Nachteile:

  • Kein Syntax-Highlighting für HTML
  • Keine Komponenten-Wiederverwendung
  • Fehleranfällig (fehlende Tags, falsche Escapes)
  • Schwer wartbar bei komplexeren Strukturen

Alternative 2: Separate Partial-Pages

Astro-Dateien als reine HTML-Fragmente:

---
// src/pages/partials/task-card.astro
export const partial = true;
const { task } = Astro.props;
---
<div class="task" data-id={task.id}>
  <span class="task-text">{task.text}</span>
  <button hx-delete={`/api/tasks/${task.id}`}>Löschen</button>
</div>

Nachteile:

  • Erfordert separate .astro-Dateien pro Fragment
  • Umständlicher Aufruf aus API-Endpoints
  • Mehr Dateien im Projekt

Container API: Das Beste aus beiden Welten

// src/pages/api/tasks.ts
import { experimental_AstroContainer } from 'astro/container';
import TaskCard from '@components/TaskCard.astro';

export const POST: APIRoute = async ({ request }) => {
  const task = await createTask(request);
  
  const container = await experimental_AstroContainer.create();
  const html = await container.renderToString(TaskCard, {
    props: { task }
  });
  
  return new Response(html, {
    headers: { 'Content-Type': 'text/html' }
  });
};

Vorteile:

  • Volle Astro-Komponenten mit Syntax-Highlighting
  • Komponenten-Wiederverwendung (gleiche Komponente für SSR und API)
  • Typ-Sicherheit durch TypeScript-Props
  • Astro-Features wie Slots, Scoped Styles, etc.
AnsatzWiederverwendbarTyp-sicherWartbarKomplexität
Template-StringsNeinNeinSchlechtNiedrig
Partial PagesJaJaMittelMittel
Container APIJaJaGutNiedrig

Use Cases im Detail

E-Mail-Templates

Transaktionale E-Mails mit Astro-Komponenten – konsistentes Design, wiederverwendbare Bausteine:

// src/lib/email.ts
import { experimental_AstroContainer } from 'astro/container';
import WelcomeEmail from '@components/emails/WelcomeEmail.astro';
import OrderConfirmation from '@components/emails/OrderConfirmation.astro';

export async function sendWelcomeEmail(user: User) {
  const container = await experimental_AstroContainer.create();
  const html = await container.renderToString(WelcomeEmail, {
    props: { userName: user.name, verifyUrl: user.verifyUrl }
  });
  
  await sendMail({
    to: user.email,
    subject: 'Willkommen!',
    html
  });
}

export async function sendOrderConfirmation(order: Order) {
  const container = await experimental_AstroContainer.create();
  const html = await container.renderToString(OrderConfirmation, {
    props: { order, items: order.items }
  });
  
  await sendMail({
    to: order.customerEmail,
    subject: `Bestellung #${order.id} bestätigt`,
    html
  });
}

Komponenten-Testing

Isolierte Tests für Astro-Komponenten:

// tests/TaskCard.test.ts
import { experimental_AstroContainer } from 'astro/container';
import TaskCard from '@components/TaskCard.astro';
import { describe, it, expect } from 'vitest';

describe('TaskCard', () => {
  it('renders task text', async () => {
    const container = await experimental_AstroContainer.create();
    const html = await container.renderToString(TaskCard, {
      props: { 
        task: { id: 1, text: 'Test Task', priority: 'high' }
      }
    });
    
    expect(html).toContain('Test Task');
    expect(html).toContain('data-id="1"');
  });
  
  it('shows high priority badge', async () => {
    const container = await experimental_AstroContainer.create();
    const html = await container.renderToString(TaskCard, {
      props: { 
        task: { id: 1, text: 'Urgent', priority: 'high' }
      }
    });
    
    expect(html).toContain('priority-high');
  });
});

Ein konkretes Beispiel aus dem AHA-Stack Demo-Projekt:

// src/pages/api/test-container.ts
import type { APIRoute } from 'astro';
import { experimental_AstroContainer } from 'astro/container';
import Hello from '@components/Hello.astro';

export const GET: APIRoute = async ({ request }) => {
  const container = await experimental_AstroContainer.create();
  
  const response = await container.renderToResponse(Hello, {
    props: { message: 'Hello via Container API' }
  });
  
  return response;
};

Die zugehörige Komponente ist simpel:

---
// src/components/Hello.astro
const { message = 'Hello from Container API' } = Astro.props;
---
<div>
  <h1>{message}</h1>
</div>

Lokal mit astro dev funktioniert das wunderbar. Der Endpoint /api/test-container rendert die Komponente und liefert HTML.

Das Problem: file:// URLs auf Edge-Runtimes

Beim Build für Cloudflare Workers kompiliert Astro den Code und generiert einen _worker.js Output. Dabei passiert etwas Problematisches: Die Container API referenziert intern Pfade mit file:// URLs.

// Im generierten Worker-Code
hrefRoot: import.meta.url
// oder direkt:
hrefRoot: "file:///path/to/project/dist/_worker.js/..."

Das Problem: Cloudflare Workers haben kein Dateisystem. file:// URLs sind im Edge-Kontext bedeutungslos und führen zu Fehlern.

Typische Fehlermeldung:

Error: Invalid URL: file:///...

Die Container API versucht, relative Pfade zu Komponenten und Assets aufzulösen – basierend auf einem lokalen Dateipfad, der auf dem Edge-Server nicht existiert.

Die Lösung: Ein Build-Hook patcht die URLs

Die Lösung ist ein Astro-Integration, die nach dem Build alle file:// URLs durch die tatsächliche Site-URL ersetzt. Das passiert als Post-Processing-Schritt.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import fs from 'node:fs';
import path from 'node:path';

export default defineConfig({
  output: 'server',
  site: 'https://aha-stack.casoon.dev',
  adapter: cloudflare({
    sessions: false,
  }),
  integrations: [
    {
      name: 'patch-container-file-urls',
      hooks: {
        'astro:build:done': async ({ dir }) => {
          const workerDir = path.join(dir.pathname, '_worker.js');
          
          if (!fs.existsSync(workerDir)) {
            console.log('[patch-container] Worker-Verzeichnis nicht gefunden');
            return;
          }
          
          const siteUrl = 'https://aha-stack.casoon.dev/';
          
          // Alle .js und .mjs Dateien im Worker-Verzeichnis durchsuchen
          const patchFiles = (directory) => {
            const entries = fs.readdirSync(directory, { withFileTypes: true });
            for (const entry of entries) {
              const fullPath = path.join(directory, entry.name);
              if (entry.isDirectory()) {
                patchFiles(fullPath);
              } else if (entry.name.endsWith('.js') || entry.name.endsWith('.mjs')) {
                let code = fs.readFileSync(fullPath, 'utf8');
                let modified = false;
                
                // Container API: hrefRoot: import.meta.url -> site URL
                if (code.includes('hrefRoot: import.meta.url')) {
                  code = code.replace(
                    /hrefRoot:\s*import\.meta\.url/g,
                    `hrefRoot: "${siteUrl}"`
                  );
                  modified = true;
                }
                
                // file:/// URLs patchen
                if (code.includes('file:///')) {
                  // new URL("file:///...") -> new URL(siteUrl)
                  code = code.replace(
                    /new URL\("file:[^"]*"\)/g,
                    `new URL("${siteUrl}")`
                  );
                  
                  // hrefRoot: "file:///..." -> site URL
                  code = code.replace(
                    /"hrefRoot":\s*"file:\/\/\/[^"]*"/g,
                    `"hrefRoot":"${siteUrl}"`
                  );
                  
                  // Dir-Pfade mit file:/// -> site URL
                  code = code.replace(
                    /"(cacheDir|outDir|srcDir|publicDir|buildClientDir|buildServerDir)":\s*"file:\/\/\/[^"]*"/g,
                    (_, key) => `"${key}":"${siteUrl}"`
                  );
                  
                  // Fallback: nackte "file:///..."-Strings
                  code = code.replace(/"file:\/\/\/[^"]*"/g, `"${siteUrl}"`);
                  modified = true;
                }
                
                if (modified) {
                  fs.writeFileSync(fullPath, code, 'utf8');
                  console.log('[patch-container] Gepatcht:', 
                    path.relative(dir.pathname, fullPath));
                }
              }
            }
          };
          
          patchFiles(workerDir);
        },
      },
    },
  ],
});

Was der Patch konkret macht

Der Hook läuft nach astro build und durchsucht rekursiv alle JavaScript-Dateien im dist/_worker.js/ Verzeichnis.

Ersetzungen im Detail:

OriginalErsetzt durch
hrefRoot: import.meta.urlhrefRoot: "https://aha-stack.casoon.dev/"
new URL("file:///...")new URL("https://aha-stack.casoon.dev/")
"hrefRoot":"file:///...""hrefRoot":"https://aha-stack.casoon.dev/"
"cacheDir":"file:///...""cacheDir":"https://aha-stack.casoon.dev/"

Die Container API nutzt diese Pfade intern zur URL-Auflösung. Indem wir sie durch die tatsächliche Site-URL ersetzen, funktioniert die Logik auch ohne Dateisystem.

Warum site: wichtig ist

Die site Option in der Astro-Config ist entscheidend:

export default defineConfig({
  site: 'https://aha-stack.casoon.dev',
  // ...
});

Diese URL wird im Patch als Ersatz für alle file:// Referenzen verwendet. Ohne korrekte site-Konfiguration würde der Patch nicht wissen, welche URL eingesetzt werden soll.

Best Practice: Die site URL sollte mit der tatsächlichen Produktions-URL übereinstimmen.

Cloudflare Workers: nodejs_compat Flag

Neben dem Patch für file:// URLs benötigt die Container API auch Node.js-kompatible APIs. Cloudflare Workers unterstützen diese über das nodejs_compat Flag:

# wrangler.toml
name = "aha-stack-example"
main = "./dist/_worker.js/index.js"
compatibility_date = "2025-11-28"
compatibility_flags = ["nodejs_compat"]

routes = [
  { pattern = "aha-stack.casoon.dev", custom_domain = true }
]

[assets]
directory = "./dist"
binding = "ASSETS"

Das nodejs_compat Flag aktiviert die Unterstützung für node:fs, node:path und andere Node.js-Module, die intern von Astro verwendet werden.

Weitere Infos: Cloudflare Node.js Compatibility

Wann braucht man diesen Patch?

Notwendig wenn:

  • Astro mit output: 'server' (SSR Mode)
  • Deployment auf Cloudflare Workers/Pages
  • Nutzung der experimental_AstroContainer API

Nicht notwendig wenn:

  • Rein statischer Build ohne SSR-Seiten
  • Deployment auf Node.js-basierte Plattformen (Vercel Functions, Railway, etc.)
  • Keine Container API Nutzung

Hinweis zu Astro 5: Der frühere output: 'hybrid' Modus wurde in output: 'static' integriert. Mit static können einzelne Seiten über export const prerender = false serverseitig gerendert werden.

Debugging: Wenn der Patch nicht greift

Manchmal funktioniert der Patch nicht wie erwartet. Hier sind typische Probleme und Lösungen:

Problem 1: “Invalid URL: file:///”

Ursache: Der Patch hat nicht alle file:// Referenzen erwischt.

Lösung: Build-Output manuell prüfen:

# Nach dem Build im Worker-Verzeichnis suchen
grep -r "file:///" dist/_worker.js/

Falls Treffer: Den Patch um das entsprechende Pattern erweitern.

Problem 2: Patch läuft, aber Fehler zur Laufzeit

Ursache: Möglicherweise fehlt das nodejs_compat Flag oder die site URL stimmt nicht.

Checkliste:

  1. wrangler.toml enthält compatibility_flags = ["nodejs_compat"]
  2. astro.config.mjs hat korrekte site URL
  3. site URL endet mit /

Problem 3: Container API findet Komponenten nicht

Ursache: Pfadauflösung schlägt fehl.

Lösung: Request-Objekt an Container übergeben:

const container = await experimental_AstroContainer.create();
// Nicht: container.renderToString(Component, { props })
// Sondern: Request-Kontext mitgeben falls nötig

Logging aktivieren

Für tieferes Debugging kann man den Patch erweitern:

if (modified) {
  fs.writeFileSync(fullPath, code, 'utf8');
  console.log('[patch-container] Gepatcht:', path.relative(dir.pathname, fullPath));
  // Zusätzlich: Welche Patterns wurden ersetzt?
  console.log('[patch-container] - hrefRoot ersetzt:', code.includes('hrefRoot'));
}

Alternativen zur Container API

Falls der Patch zu fragil erscheint oder die Container API für den Use Case überdimensioniert ist:

Alternative: Template-Literal-Funktionen

Typisierte Helper-Funktionen für HTML-Fragmente:

// src/lib/templates.ts
interface Task {
  id: number;
  text: string;
  priority: 'low' | 'medium' | 'high';
}

export function renderTaskCard(task: Task): string {
  const priorityClass = `priority-${task.priority}`;
  return `
    <div class="task ${priorityClass}" data-id="${task.id}">
      <span class="task-text">${escapeHtml(task.text)}</span>
      <div class="task-actions">
        <button hx-delete="/api/tasks/${task.id}" 
                hx-target="closest .task" 
                hx-swap="outerHTML">
          Löschen
        </button>
      </div>
    </div>
  `;
}

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

Wann sinnvoll:

  • Einfache, kleine Fragmente
  • Keine komplexen Komponenten-Hierarchien
  • Performance kritisch (kein Container-Overhead)

Alternative: Astro Endpoints mit fetch

Interne Anfrage an eine Partial-Page:

// src/pages/api/tasks.ts
export const POST: APIRoute = async ({ request, url }) => {
  const task = await createTask(request);
  
  // Interne Anfrage an Partial
  const partialUrl = new URL('/partials/task-card', url);
  partialUrl.searchParams.set('taskId', task.id.toString());
  
  const response = await fetch(partialUrl);
  const html = await response.text();
  
  return new Response(html, {
    headers: { 'Content-Type': 'text/html' }
  });
};

Wann sinnvoll:

  • Partials bereits vorhanden
  • Kein Zugriff auf Container API (ältere Astro-Version)

Das Beispielprojekt

Das komplette Beispiel ist auf GitHub verfügbar und live deployed:

Live-Demo: aha-stack.casoon.dev

GitHub Repository: github.com/casoon/aha-stack-example

Das Projekt ist ein Kanban-Board, das den AHA-Stack demonstriert – Astro + HTMX + Alpine.js. Der Patch macht es möglich, dass das Projekt auf Cloudflare Workers läuft, obwohl es potentiell die Container API nutzen kann.

Deployment auf Cloudflare Workers

Das Deployment erfolgt entweder über die Wrangler CLI oder direkt über GitHub-Integration:

Option 1: Wrangler CLI

# Repository klonen
git clone https://github.com/casoon/aha-stack-example.git
cd aha-stack-example

# Dependencies installieren
pnpm install

# Lokal testen
pnpm dev

# Build (mit automatischem Patch)
pnpm build

# Deploy auf Cloudflare Workers
pnpm deploy

Option 2: GitHub-Integration

Cloudflare Workers können direkt mit einem GitHub-Repository verbunden werden. Bei jedem Push wird automatisch gebaut und deployed. Die Pages-Integration wird von Cloudflare zugunsten von Workers ausgebaut.

Zusammengefasst

Die Astro Container API ist mächtig – aber auf Edge-Runtimes wie Cloudflare Workers nicht ohne Weiteres nutzbar. Der vorgestellte Build-Hook löst das Problem pragmatisch, indem er alle file:// Referenzen durch die tatsächliche Site-URL ersetzt.

Wichtige Punkte:

  • Der Patch ist eine Build-Zeit-Lösung, kein Runtime-Workaround
  • Die site Config muss korrekt gesetzt sein
  • Der Patch durchsucht rekursiv alle Worker-Dateien

Sobald die Container API aus dem experimentellen Status herauskommt, wird Astro vermutlich native Unterstützung für Edge-Runtimes bieten. Bis dahin ist dieser Workaround eine zuverlässige Lösung.

Im zweiten Teil dieser Artikelserie geht es um den AHA-Stack selbst – wie Astro, HTMX und Alpine.js zusammenarbeiten, um interaktive Webanwendungen ohne schwere JavaScript-Frameworks zu bauen.