Adapter-Design: wo Frameworks enden
Was ein Framework-Adapter wirklich leistet: der Vertrag über den Build-Output, die Übersetzung von Request und Response und die stillen Umgebungsannahmen an der Grenze zwischen SvelteKit und AWS.
Ein Adapter im SvelteKit-Sinn ist kein Entwurfsmuster aus dem Lehrbuch und auch kein Deployment-Werkzeug. Er ist ein Stück Code, das nach dem Build läuft, den Output des Frameworks entgegennimmt und daraus etwas erzeugt, das eine konkrete Plattform ausführen kann: ein Node-Server, eine Lambda-Funktion, ein Worker am Netzwerkrand. Er startet keine Prozesse, er bedient keine Requests, er provisioniert im Kern keine Infrastruktur. Er übersetzt.
Die Definition klingt harmlos. Interessant ist, was sie impliziert: Ein Adapter ist genau die Stelle, an der ein Framework aufhört, Verantwortung zu übernehmen – und die Plattform anfängt. Alles vor dieser Stelle gehört dem Framework: Routing, Rendering, Datenladen, die Semantik einer Seite. Alles danach gehört der Plattform: Prozessmodell, Netzwerk, Dateisystem, Limits, Preisschild. Wer einen Adapter schreibt, zieht diese Grenze nicht selbst, aber er muss sie präzise nachzeichnen. Und dabei fällt auf, wie viele Entscheidungen auf beiden Seiten stillschweigend getroffen wurden.
Seit Anfang des Jahres pflege ich einen solchen Adapter: sveltekit-adapter-aws bringt SvelteKit-Anwendungen per CDK auf Lambda, S3 und CloudFront. Das Projekt selbst habe ich bereits vorgestellt – hier geht es um das, was ich beim Bauen über die Schnittstelle gelernt habe. Denn die eigentliche Erkenntnis steckt nicht im AWS-Teil, sondern in der Frage, die jeder Adapter beantworten muss: Was gehört auf welche Seite?
Der Vertrag über den Build-Output
SvelteKit gibt einem Adapter erstaunlich wenig – und genau das ist gut. Die Schnittstelle besteht aus einem Objekt mit einem Namen und einer adapt-Funktion, die ein builder-Objekt bekommt. Der Builder ist der komplette Vertrag: Er liefert die Client-Assets, die prerenderten Seiten und den Server-Code, dazu ein Manifest mit den Routen-Metadaten. Mehr nicht.
import { writeFileSync } from 'node:fs';
export function adapter(options = {}) {
return {
name: 'sveltekit-adapter-aws',
async adapt(builder) {
const out = 'build';
builder.rimraf(out);
builder.writeClient(`${out}/assets`); // static files for the CDN
builder.writePrerendered(`${out}/prerendered`);
builder.writeServer(`${out}/server`); // the SSR part
const manifest = builder.generateManifest({ relativePath: './' });
writeFileSync(
`${out}/server/manifest.js`,
`export const manifest = ${manifest};`
);
}
};
}
Das Framework sagt damit: Es gibt drei Sorten von Artefakten, und ich schreibe sie dir dorthin, wo du sie haben willst. Wohin sie am Ende gehören, wie sie ausgeliefert werden, was ein „Server" auf deiner Plattform überhaupt ist – dein Problem. Für AWS heißt das konkret: Client-Assets und prerenderte Seiten wandern in einen S3-Bucket hinter CloudFront, der Server-Code wird mit esbuild zu einem Lambda-Bundle gebaut, und die Routing-Regeln der CDN-Verteilung müssen entscheiden, welcher Request an S3 geht und welcher an die Funktion.
flowchart LR
subgraph FW[Framework-Seite]
SRC[Quellcode] --> VITE[Vite-Build]
VITE --> CL[Client-Assets]
VITE --> PR[Prerenderte<br/>Seiten]
VITE --> SV[Server-Code +<br/>Manifest]
end
subgraph AD[Adapter]
CL --> MAP[Zuordnung,<br/>Bundling]
PR --> MAP
SV --> MAP
end
subgraph PF[Plattform-Seite]
MAP --> S3[S3-Bucket]
MAP --> LB[Lambda-Bundle]
S3 --> CF[CloudFront]
LB --> CF
end
Die Zuordnung im mittleren Kasten ist der Teil, den niemand sieht und in dem die meiste Arbeit steckt. Welche Pfade sind statisch, welche dynamisch? Was passiert mit einer prerenderten Seite, die auch eine Server-Route hat? Wer setzt die Cache-Header? Das Framework hat dazu eine Meinung, aber es erzwingt sie nicht – es kann sie gar nicht erzwingen, weil es die Plattform nicht kennt. Der Adapter muss die Meinung des Frameworks in die Mechanik der Plattform übersetzen, ohne dabei eine eigene, dritte Meinung zu erfinden.
Request und Response als gemeinsame Sprache
Zur Laufzeit ist der Vertrag noch schmaler. Der SvelteKit-Server spricht die Web-Standards: Er nimmt ein Request-Objekt aus der Fetch-API entgegen und gibt ein Response-Objekt zurück. Das ist eine bewusste und, wie ich finde, kluge Entscheidung – dieselben Typen, die im Browser seit Jahren existieren und die Deno Deploy und Cloudflare Workers ohnehin als natives Format verwenden. Die frisch gegründete WinterCG versucht gerade, genau diesen Kern serverseitiger Web-APIs über Laufzeitumgebungen hinweg zu vereinheitlichen. SvelteKit hat sich schon vorher darauf festgelegt.
Für den Adapter bedeutet das: Seine Laufzeit-Aufgabe ist eine Hin- und eine Rückübersetzung. AWS spricht nicht Fetch, AWS spricht API-Gateway-Events. Der Handler in meinem Lambda-Bundle tut deshalb im Kern nur das hier:
import { Server } from './index.js';
import { manifest } from './manifest.js';
const server = new Server(manifest);
await server.init({ env: process.env });
export async function handler(event) {
const request = toRequest(event); // API Gateway event -> fetch Request
const response = await server.respond(request, {
getClientAddress: () => event.requestContext.http.sourceIp
});
return toLambdaResult(response); // fetch Response -> Lambda result
}
Die beiden unscheinbaren Funktionen toRequest und toLambdaResult sind der Ort, an dem die Plattform ihre Eigenheiten zeigt. Ein API-Gateway-Event trägt Query-Parameter bereits zerlegt, der Request will eine vollständige URL. Binäre Antworten müssen Base64-kodiert werden, und ob etwas „binär" ist, entscheidet der Adapter anhand des Content-Types – eine Heuristik, die man irgendwann zum ersten Mal falsch hat. Und Set-Cookie ist der Klassiker: Mehrere Cookies in einer Antwort werden von der Headers-Implementierung zu einer Zeile zusammengezogen, während API Gateway sie als separates Array erwartet.
async function toLambdaResult(response) {
const contentType = response.headers.get('content-type') || '';
const isBinary = !/^(text\/|application\/(json|javascript))/.test(contentType);
return {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
// API Gateway expects cookies as a separate array
cookies: splitSetCookie(response.headers.get('set-cookie')),
body: isBinary
? Buffer.from(await response.arrayBuffer()).toString('base64')
: await response.text(),
isBase64Encoded: isBinary
};
}
Nichts davon ist schwierig. Aber alles davon ist Verantwortung, die das Framework ausdrücklich nicht übernimmt – und auch nicht übernehmen sollte. Würde SvelteKit anfangen, API-Gateway-Eigenheiten zu kennen, müsste es morgen auch die von Azure, Google und jedem Hoster kennen, der eine eigene Event-Struktur erfindet. Die schmale Fetch-Schnittstelle ist die Grenze, die beide Seiten unabhängig hält.
sequenceDiagram participant C as Client participant CF as CloudFront participant L as Lambda-Handler participant K as SvelteKit-Server C->>CF: HTTP-Request CF->>L: API-Gateway-Event L->>K: fetch Request K-->>L: fetch Response L-->>CF: statusCode, headers,<br/>cookies, body CF-->>C: HTTP-Response
Die stillen Annahmen der Umgebung
Der dritte Teil der Grenze ist der unangenehmste, weil er in keiner Schnittstellen-Signatur steht: die Annahmen über die Umgebung. Eine SvelteKit-Anwendung darf in ihren Server-Routen fast beliebigen Node-Code ausführen. Was davon auf der Zielplattform funktioniert, weiß nur der Adapter – und oft erst der Betrieb.
Auf Lambda sind mir drei Punkte immer wieder begegnet:
- Das Dateisystem ist schreibgeschützt, bis auf
/tmp. Wer im Server-Code Dateien ablegt, Caches auf Platte schreibt oder eine SQLite-Datei neben dem Code erwartet, fällt beim ersten echten Request auf die Nase – lokal mitvite devfunktioniert alles. - Umgebungsvariablen kommen aus der Funktionskonfiguration, nicht aus einer
.env-Datei. SvelteKit hat dafür inzwischen den Hookserver.init({ env }), über den der Adapter die Variablen explizit hineinreicht. Genau solche kleinen Übergabepunkte machen die Grenze ehrlich: Die Umgebung ist ein Parameter, kein globaler Zustand. - Streaming gibt es nicht. Lambda hinter API Gateway puffert die komplette Antwort und liefert sie am Stück aus, mit harten Größenlimits. Eine
Responsemit einem langsamen Stream als Body ist auf dieser Plattform schlicht nicht abbildbar – der Adapter muss den Stream einsammeln und materialisieren.
Der letzte Punkt zeigt, warum Adapter-Design mehr ist als Format-Konvertierung. Das Framework verspricht mit dem Response-Typ eine Fähigkeit – streamende Bodies –, die die Plattform nicht einlösen kann. Der Adapter kann das nicht reparieren, er kann es nur sichtbar machen: dokumentieren, im Zweifel beim Build warnen, und die Antwort korrekt puffern statt sie abzuschneiden. Wo Framework-Versprechen und Plattform-Fähigkeit auseinanderlaufen, ist der Adapter derjenige, der die Differenz erklären muss.
Wenn sich die Framework-Seite bewegt
SvelteKit ist im Oktober 2022 immer noch pre-1.0, und das merkt man als Adapter-Autor deutlicher als als Anwendungsentwickler. Seit Anfang des Jahres habe ich die Adapter-Integration mehrfach anfassen müssen: Aus app.render(request) wurde server.respond(request, options), das Manifest-Format und die Builder-Methoden haben sich verschoben, und die große Routing-Umstellung im Spätsommer hat verändert, was überhaupt im Server-Output landet. Mit dem Wechsel auf Vite 3 kam noch die Werkzeug-Ebene dazu.
Ich will das nicht als Klage verstanden wissen – ein Framework vor 1.0 darf seine Schnittstellen ändern, dafür ist die Phase da. Aufschlussreich war etwas anderes: Jede dieser Änderungen hat mir gezeigt, welche meiner Annahmen zum Vertrag gehörten und welche ich mir nur eingebildet hatte. Wer eine Schnittstelle dreimal nachziehen muss, versteht sie danach besser als jemand, der sie einmal gelesen hat.
Praktisch habe ich daraus zwei Konsequenzen gezogen. Erstens: Die SvelteKit-Version wird im Adapter-Projekt exakt gepinnt, und jede Aktualisierung ist ein bewusster Schritt mit eigenem Commit statt eines beiläufigen npm update. Zweitens: Der wichtigste Test ist kein Unit-Test der Konvertierungsfunktionen, sondern ein Beispielprojekt, das bei jeder Änderung wirklich gebaut und deployt wird. Ein Adapter ist Konsument einer instabilen API; die einzige belastbare Prüfung ist die, die den ganzen Weg geht – vom vite build bis zur HTTP-Antwort aus CloudFront. Alles andere testet nur die eigene Vorstellung vom Vertrag, nicht den Vertrag.
Dieselbe Grenze, andere Frameworks
Die Frage „was gehört auf welche Seite" stellt sich überall, nur beantworten die Frameworks sie unterschiedlich – und die Antworten sind aufschlussreich.
Next.js hat keine Adapter-Schnittstelle. Die Plattform ist implizit Vercel, und wer woanders deployt, arbeitet sich an einem Output ab, der dafür nie gedacht war. Bemerkenswert finde ich, dass Vercel dieses Jahr mit der Build-Output-API den Spieß umgedreht hat: Dort definiert die Plattform ein dokumentiertes Verzeichnisformat, und beliebige Frameworks können sich darauf hinbauen. Das ist dieselbe Grenze, nur von der anderen Seite gezeichnet – statt „Framework definiert, Adapter übersetzt" gilt „Plattform definiert, Framework liefert an". Wer den Vertrag besitzt, hat die Macht über die Grenze.
Remix wiederum hat die Übersetzung von Anfang an eingebaut: createRequestHandler nimmt plattformspezifische Requests entgegen, und offizielle Adapter existieren für Express, Architect und Cloudflare Workers. Auch dort ist der Kern identisch mit dem, was SvelteKit macht – Web-Standard-Request rein, Web-Standard-Response raus, und drumherum eine dünne Schicht Plattform-Dialekt. Je konsequenter sich Frameworks auf diese Standards festlegen, desto dünner werden die Adapter. Im besten Fall schrumpft die Laufzeit-Übersetzung auf null, wie bei Deno Deploy und den Workers, die Fetch nativ sprechen, und es bleibt nur noch der Build-Output-Teil übrig.
Was ich daraus mitnehme
Ein Adapter ist die ehrlichste Stelle einer Framework-Architektur, weil sich dort nichts wegabstrahieren lässt. Auf der einen Seite steht, was das Framework wirklich garantiert: drei Sorten Artefakte, ein Manifest, eine Fetch-Schnittstelle. Auf der anderen Seite steht, was die Plattform wirklich verlangt: ihr Event-Format, ihr Dateisystem, ihre Limits. Der Adapter dazwischen sollte langweilig und dünn sein. Wird er dick, ist das ein Befund – dann garantiert das Framework zu wenig, oder die Plattform verlangt zu viel, und beides sollte man wissen, bevor man eine Anwendung darauf baut.
Deshalb lohnt sich der Blick in den Adapter auch für alle, die nie einen schreiben werden. Wer versteht, was adapter-node oder ein AWS-Adapter im Detail tun, weiß genau, welche Annahmen die eigene Anwendung an ihre Umgebung stellt – und welche davon beim nächsten Plattformwechsel brechen. Die Grenze zwischen Framework und Plattform verschwindet nicht dadurch, dass man sie ignoriert. Sie wird nur unsichtbar. Ein guter Adapter macht sie wieder sichtbar, und das ist mehr wert als jedes Feature, das er sonst noch mitbringt.
Weiterführende Quellen
- Mein Adapter-Projekt: https://github.com/MikeBild/sveltekit-adapter-aws
- SvelteKit-Doku zu Adaptern: https://svelte.dev/docs/kit/adapters
- SvelteKit-Doku „Writing adapters": https://svelte.dev/docs/kit/writing-adapters
- Remix Server Adapters: https://v2.remix.run/docs/other-api/adapter
Kommentare