Cap'n Web: Ein neues RPC-System für Browser und Webserver
(blog.cloudflare.com)- Cap'n Web ist ein neues, in TypeScript implementiertes RPC-Protokoll, das für Web-Umgebungen optimiert ist und in verschiedenen JavaScript-Runtimes läuft
- Es bietet JSON-basierte Serialisierung und ein menschenlesbares Datenformat – ganz ohne Schema oder umständlichen Boilerplate-Code
- Durch ein Object-Capability-basiertes Modell sind bidirektionale Aufrufe, die Übergabe von Funktions- und Objektreferenzen, Promise-Pipelining und die Umsetzung von Sicherheitsmustern möglich
- Es unterstützt verschiedene Netzwerkumgebungen wie WebSocket, HTTP, postMessage und ist ein leichtgewichtiges Open-Source-Projekt mit weniger als 10 kB
- Es löst nicht nur das Waterfall-Problem ähnlich wie GraphQL, sondern ermöglicht auch eine natürliche Modellierung von RPCs wie bei normalen JavaScript-APIs
Was ist Cap'n Web?
- Cap'n Web ist ein von Cloudflare entwickeltes Open-Source-RPC-System auf TypeScript-Basis
- Es ist von Cap'n Proto inspiriert, arbeitet aber ohne separate Schemadefinition und nutzt eine menschenfreundliche Serialisierung auf JSON-Basis
- Es ist in TypeScript integriert und verbessert damit die Developer Experience durch Autovervollständigung, Type-Checking usw.; Laufzeit-Typvalidierung kann separat behandelt werden, etwa mit Type Guards
- Es unterstützt Netzwerkprotokolle wie HTTP, WebSocket und postMessage und läuft in gängigen Browsern, Cloudflare Workers, Node.js und mehr
- Dank der leichtgewichtigen, abhängigkeitslosen Struktur bleibt es bei minify + gzip unter 10 kB
Das Object-Capability-Modell (OCap) von Cap'n Web
- Es verwendet ein Object-Capability-Modell, das mehr Ausdrucksmöglichkeiten bietet als herkömmliche RPC-Systeme
- Bidirektionale Aufrufe: Client und Server können gegenseitig Funktionen aufrufen
- Übergabe von Funktions- und Objektreferenzen: Wenn Funktionen oder Objekte per RPC übergeben werden, erhält die Gegenseite einen Stub, dessen Aufruf am Ursprungsort ausgeführt wird
- Promise Pipelining: Beim Verketten mehrerer RPCs ist nur ein einziger Netzwerk-Roundtrip nötig
- Sicherheitsmuster: Sicherheitskontrollen wie Autorisierung und Session-Management lassen sich auf natürliche Weise umsetzen
Grundlegende Nutzung
-
Client-Beispiel
import { newWebSocketRpcSession } from "capnweb" let api = newWebSocketRpcSession("wss://example.com/api") let result = await api.hello("World") console.log(result) -
Server-Beispiel (auf Basis eines Cloudflare Workers)
import { RpcTarget, newWorkersRpcResponse } from "capnweb" class MyApiServer extends RpcTarget { hello(name) { return `Hello, ${name}!` } } export default { fetch(request, env, ctx) { let url = new URL(request.url) if (url.pathname === "/api") { return newWorkersRpcResponse(request, new MyApiServer()) } return new Response("Not found", {status: 404}) } } -
Zusätzliche Methoden für die API, die Übergabe von Callback-Funktionen vom Client sowie die Definition und Anwendung von TypeScript-Interfaces lassen sich einfach umsetzen
Was ist RPC und was zeichnet Cap'n Web aus?
- RPC (Remote Procedure Call) ist ein Konzept, mit dem zwei Programme über ein Netzwerk so kommunizieren können, als würden sie Funktionen aufrufen
- Anders als bei traditionellen HTTP/REST-Protokollen ermöglicht RPC durch die Abstraktion von Funktionsaufrufen Code, der der Denkweise von Entwickler:innen entspricht
- Cap'n Web passt gut zum modernen JavaScript-Stil mit async/await, Promise und Exception-Support
- Anders als bei den historischen Kontroversen um RPC (synchrone Aufrufe, Netzwerkfehler) ist eine Nutzung in modernen JS-Umgebungen heute sicherer und effizienter möglich
Einsatzszenarien für Cap'n Web
- Geeignet für alle Umgebungen, in denen Netzwerkkommunikation zwischen zwei JavaScript-Anwendungen nötig ist
- Client-Server-Kommunikation, Aufrufe zwischen Microservices usw.
- Besonders geeignet für Web-Apps mit Echtzeit-Zusammenarbeit und für Interaktionen über komplexe Sicherheitsgrenzen hinweg
- Noch in einer experimentellen Phase und daher besonders interessant für Entwickler:innen, die offen für moderne Technologien sind
Verschiedene Funktionen
HTTP-Batch-Modus
-
Wenn keine dauerhafte Verbindung nötig ist, können im HTTP-Batch-Modus mehrere RPC-Aufrufe gebündelt und auf einmal verarbeitet werden
import { newHttpBatchRpcSession } from "capnweb" let batch = newHttpBatchRpcSession("https://example.com/api") let result = await batch.hello("World") console.log(result) -
Innerhalb eines einzelnen Batches können mehrere Aufrufe gleichzeitig ausgeführt und die Ergebnisse parallel empfangen werden
let promise1 = batch.hello("Alice") let promise2 = batch.hello("Bob") let [result1, result2] = await Promise.all([promise1, promise2])
Promise Pipelining (verkettete Aufrufe)
-
Unterstützt ein Modell, bei dem das Ergebnis eines vorherigen Aufrufs direkt als Argument für den nächsten Aufruf verwendet wird, ohne auf die vorherige Antwort zu warten
-
Beispiel: Das Ergebnis-Promise von
getMyName()wird direkt anhello()übergeben und mit nur einem Netzwerk-Roundtrip verarbeitetlet namePromise = batch.getMyName() let result = await batch.hello(namePromise) -
Die Promises von Cap'n Web verhalten sich wie Proxy-Objekte, sodass zusätzliche Methodenaufrufe ohne Verzögerung verkettet werden können
let sessionPromise = batch.authenticate(apiKey) let name = await sessionPromise.whoami()
Sicherheit: Authentifizierung und Object Capability
- Über die Methode
authenticatewird bei Erfolg ein Berechtigungsobjekt (Session) vergeben; danach können Funktionen ohne zusätzliche Authentifizierungsschritte aufgerufen werden - Anders als bei klassischen RPCs kann ein Session-Objekt nicht gefälscht werden, und auf Methoden mit Berechtigungsanforderungen kann ohne Authentifizierung nicht zugegriffen werden
- Strukturelle Einschränkungen von WebSocket werden auf natürliche Weise überwunden, während die Konsistenz der Authentifizierungslogik gewahrt bleibt
- Wenn API-Interfaces in TypeScript deklariert werden, lassen sie sich automatisch auf Client und Server anwenden und sorgen für Autovervollständigung sowie Typsicherheit
Vergleich mit GraphQL und das Alleinstellungsmerkmal von Cap'n Web
-
GraphQL entschärft das Waterfall-Problem von REST, erfordert aber die Einführung einer neuen Sprache, eines neuen Schemas und einer neuen Toolchain
-
Cap'n Web löst das Waterfall-Problem allein mit JavaScript-Code und
- unterstützt Promise Pipelining und Objektreferenzen, wodurch sich verschachtelte Aufrufe oder komplexe Transaktionslogik auf natürliche Weise modellieren lassen
let user = api.createUser({ name: "Alice" }) let friendRequest = await user.sendFriendRequest("Bob") -
Ohne die Komplexität sowie Lern- und Verwaltungskosten von GraphQL lässt es sich ähnlich wie eine JavaScript-API nutzen
Array-Operationen (array.map usw.) und Optimierung
-
Mit Cap'n Web sind
map-Operationen auf jedem Element eines Arrays ohne zusätzliche Netzwerk-Roundtrips möglich -
Die Callback-Funktion von
mapwird einmal auf dem Client ausgeführt, wobei der Recheninhalt aufgezeichnet wird (Record-Replay), dann an den Server gesendet und dort gesammelt verarbeitetlet friendsWithPhotos = friendsPromise.map(friend => { return {friend, photo: api.getUserPhoto(friend.id)} }) let results = await friendsWithPhotos -
Über eine begrenzte domänenspezifische Sprache (DSL) lässt sich dies wie eine JavaScript-Funktion ausdrücken, während intern das Cap'n-Web-Protokoll zur Optimierung mehrerer Aufrufe verwendet wird
Interne Protokollstruktur und Kommunikationsablauf
- Strukturierte Datenübertragung über JSON plus spezielle Vorverarbeitung, mit Unterstützung für spezielle Typen wie Arrays und Datumswerte
- Als symmetrisches Protokoll ermöglicht es bidirektionale Kommunikation ohne feste Trennung zwischen Client und Server
- Jede Partei (zum Beispiel Alice und Bob) verwaltet Export-/Import-Tabellen und unterscheidet Objekt- und Funktionsreferenzen anhand von IDs
- Durch Push-/Pull-Nachrichten und die Vergabe von Promise-IDs können viele Aufrufe in einem einzigen Roundtrip verarbeitet werden
Status und Einsatzbeispiele
- Cap'n Web ist noch experimentelle Open Source, wird aber bereits in realen Diensten wie den Remote Bindings von Cloudflare Wrangler eingesetzt
- Weitere Blogposts und verschiedene Frontend-Experimente sind geplant
- Es wird unter der MIT-Lizenz veröffentlicht und ist frei für alle nutzbar
- Direkt zum GitHub-Repository
1 Kommentare
Hacker-News-Kommentare
Ich habe zwei Fragen.
grpc/avrousw.) versuchen, dieses Problem direkt zu lösen.Ich halte das für wirklich innovative Arbeit.
Falls es ein Subscription-Objekt mit Callback gibt, sollte man die API so entwerfen, dass beim Start der „zuletzt gesehene Nachricht“-Stand angegeben werden kann. Dann kann der Datenstrom direkt nahtlos fortgesetzt werden, ohne dass zwischendurch etwas verloren geht.
Ich sollte dazu wohl mal eine Blogpost-Serie über solche Designmuster schreiben.
Der Abschnitt darüber, wie das Array-Problem gelöst wurde, ist wirklich interessant und gleichzeitig ein wenig beängstigend: Blog-Link
Im Fall von
.map()wird nicht direkt JavaScript-Code an den Server geschickt, aber doch etwas Code-Ähnliches, und zwar mithilfe einer begrenzten domänenspezifischen Sprache (DSL). Auf Client-Seite wird der Callback einmal mit Platzhalterwerten ausgeführt, sein Verhalten per Record-Replay nachverfolgt und dann ein Instruction-Set an den Server geschickt. Dort werden diese Instruktionen empfangen und für jedes Array-Mitglied ausgeführt.Der Entwickler verwendet also einfach normale JS-Methoden, tatsächlich wird das Ganze aber per Trick in eine enge DSL umgewandelt. Callbacks dürfen nur synchron arbeiten,
awaitist nicht möglich. Stattdessen ist nur Promise-Pipelining erlaubt, sodass der gesamte Ablauf erfasst und an den Server übergeben werden kann, wo er bei Bedarf erneut ausgeführt wird.In C# gibt es dafür Expression Trees. Entity Framework nutzt sie, um Lambdas entgegenzunehmen und in SQL-Abfragen umzuwandeln. Man kann den Code also verwenden, indem man ihn scannt oder transformiert, ohne ihn tatsächlich auszuführen.
Zum Beispiel ist bei
db.People.Where(p => p.Name == "Joe")Wherekeine Funktion, die wirklich ein Predicate entgegennimmt, sondern einen Ausdruck. Der übergebene Code wird also gescannt, es wird geprüft, ob das FeldNamemit"Joe"übereinstimmt, und daraus wird eine SQL-WHERE-Klausel erzeugt.JavaScript hat keinen solchen Mechanismus, daher wird es emuliert, indem Platzhalterwerte eingefügt und dann Schritt für Schritt aufgezeichnet wird, wie sich der Code verhält.
Beim Erstellen der Query-DSL von Tanstack DB wurde dieser Record-Replay-Trick kürzlich ebenfalls verwendet: Guide-Link. Den
where-/select-/join-Callbacks werdenRefProxy-Objekte übergeben, und es wird verfolgt, welche Properties/Operationen auf diesen Objekten stattfinden.Da sich in JS allgemeine Operatoren (
==,>usw.) nicht direkt abfangen lassen, erstellt man kleine, nachvollziehbare Funktionen wieeq/gt/not, führt den Callback nur einmal aus, fängt den verknüpften Ausdruck ab und baut daraus eine IR.Erstaunlicherweise konnte sogar der JS-Spread-Operator nachverfolgt werden.
Kenton, könntest du dieses Konzept vielleicht auch in Cap'n Web aufnehmen und Fake-Operatoren (
eq,gt,inusw.) hinzufügen, um Remote-Tracing zu ermöglichen?Bedingungen scheinen verboten zu sein (fast wie bei den Regeln für React Hooks). Mich würde interessieren, wie solche Einschränkungen umgesetzt werden.
Dieses Projekt ist faszinierend.
Es hat Ähnlichkeiten mit ML-Compiler-Bibliotheken (TensorFlow 1,
JAX jit,PyTorch compileusw.). Per Tracing wird ein Operationsgraph aufgebaut, der dann kompiliert oder für eine VM transformiert und ausgeführt wird.Derzeit dienen dynamische Sprachen als Frontend, um keine neue DSL definieren zu müssen; stattdessen werden AST-Transformationen in bestehende Skriptsprachen eingebettet.
In ML wird die Ausführung von GPU-/Linalg-Kernels verzögert, um Kernel zusammenzufassen; bei einem RPC wie Cap'n Web kann man Netzwerk-Requests verzögern, um mehrere Network-Calls zusammenzufassen.
Im Kern geht es darum, Instruction Plane und Data Plane zu trennen, und selbst eine einzelne CPU in sehr kleinem Maßstab besitzt eine Distributed-System-Struktur (Trennung von Befehls- und Datencache).
In Cap'n Web übernimmt der RPC-Graph selbst die Rolle der Instruktionen.
Dieses Muster ist wirklich spannend, aber es fühlt sich auch so an, als würde sich die Stack-Struktur (Compiler über Interpreter, Interpreter über Compiler ...) endlos wiederholen. Es wirkt wie eine weitere Variante des lispy-Musters „code is data, data is code“. Ich habe das Gefühl, dass dahinter eine noch grundlegendere Geschichte steckt.
Dynamische Sprachen werden jetzt zum Frontend neuer DSLs, und statt neue Syntax festzulegen, bettet man die AST-Erzeugung direkt in Skripte ein.
Ich denke, TypeScript ist hier ein Gamechanger. Man bekommt sowohl die Laufzeitflexibilität von JavaScript (wie Cap'n Web mit clever eingesetzten Proxys) als auch Typsicherheit.
In letzter Zeit bin ich von diesem Konzept im ORM-Bereich regelrecht besessen. Die meisten ORMs arbeiten seriell und eager, sodass man sie erst direkt vor der Query-Ausführung manipulieren kann.
Ein wirklich composable ORM müsste meiner Meinung nach wie ein Compiler arbeiten: Man definiert in TypeScript eine vollständig typsichere DSL über SQL, baut daraus eine Query-AST und kompiliert erst ganz am Ende zu SQL.
Typegres, an dem ich gerade arbeite, verfolgt genau diese Idee. Wenn dich dieses Muster interessiert, könnte es einen Blick wert sein.
Das Kernproblem von RPC-Bibliotheken ist, dass sie zu verbergen versuchen, wo und wie Round-Trips stattfinden.
Schon bei
.map()auf Arrays in Cap'n Web ist schwer zu erkennen, wo genau tatsächlich ein Network-Round-Trip auftritt.Ich halte das nicht für ein „Feature“, sondern eher für einen „Bug“ — wenn man den Code liest, sollte man sofort verstehen können, wie er sich verhält; das zu verschleiern ist nicht wünschenswert.
Referenz-Link
awaitverwendet wird.Promise-Pipelining ermöglicht es, mehrere Statements ohne
awaitnacheinander aufzusetzen, daher gibt es dazwischen keine zusätzlichen Network-Round-Trips. Ein einzigesawaitam Ende ist alles.Wenn man schon mit
gRPCund dem Web gearbeitet hat, weiß man, wie schmerzhaft es ist, Protobuf fürs Web nutzbar zu machen.Die Einfachheit von Cap'n Web gefällt mir wirklich gut: Cap'n-Proto-Dokumentation
Anders als Cap'n Proto hat Cap'n Web überhaupt kein Schema. Es gibt fast keinen unnötigen Boilerplate-Code, weshalb es sich stark nach nativer JavaScript-RPC in Cloudflare Workers anfühlt.
GitHub-Referenz
Ich habe entdeckt, dass
kentonveine neue Bibliothek veröffentlicht hat, und bin sofort hergekommen.Als ich mir den Code auf GitHub ansah, war ich überrascht, wie klein der Umfang tatsächlich ist. Ich frage mich, ob das wirklich alles ist.
Theoretisch scheint es auch nicht allzu schwer zu sein, die Server-Seite in andere Sprachen zu portieren; ich hätte Lust, es mit einem Elixir-Server und einem JS/TS-Frontend auszuprobieren.
Es wäre auch interessant, so ein Sprach-Porting einem LLM zu überlassen. Mich würde interessieren, ob in diesem Repo LLM-basierter Code steckt. Ich habe vor ein paar Monaten irgendwo gesehen, dass
kentonvein von KI erzeugtes (von Menschen überprüftes) POC gebaut hatte.Ich glaube nicht, dass ein LLM diese Bibliothek zum jetzigen Zeitpunkt hätte entwickeln können. Die interne Struktur ist wie ein sehr fein verzahntes Puzzle aufgebaut.
Tatsächlich hat die Designarbeit mehr Zeit gekostet als der eigentliche Code.
Das ist etwas völlig anderes als die Bibliothek
workers-oauth-provider, die eine bekannte Spezifikation auf neuartige Weise implementiert.Die Code-Struktur dürfte sich relativ leicht in dynamische Sprachen wie Python portieren lassen, in statisch typisierte Sprachen aber eher nicht. Es gibt viele Stellen, die von beliebigen Objekttypen abhängen.
Es gibt Ähnlichkeiten mit OCapN, aber auch wichtige Unterschiede: Referenz
Beide unterstützen Capability-Transfer, Promise-Pipelining und ein schemaloses Modell.
Cap'n Web hat keine Out-of-Band-Capabilities wie
sturdyrefin OCapN (wiederherstellbare URIs). Deshalb vermute ich, dass API-Key-Authentifizierung notwendig ist. Einsturdyrefist eine Art nicht erratbares Token; wer es besitzt, erhält Zugriffsrechte auf den entsprechenden Endpunkt.Außerdem hat Cap'n Web keine Drei-Parteien-Übergabe, bei der Alice Bob bei Carol einführt. Für verteilte Apps ist das essenziell, daher wirkt Cap'n Web eher wie ein traditioneller SaaS-artiger Client-Server-Einsatz mit einigen
ocap-Eigenschaften.Bei
SturdyRefhängt die Wiederherstellung je nach Plattform unterschiedlich ab, daher halte ich es für sinnvoller, das plattformspezifisch umzusetzen als auf Ebene des RPC-Protokolls.In Cloudflare Workers wird es zum Beispiel bald möglich sein, Capabilities im Durable-Object-Storage persistent zu speichern, aber die Umsetzung ist stark workers-spezifisch.
Auch Sandstorm kennt persistente Capabilities, allerdings nur für interne Dienste.
Deshalb wurde das Konzept persistenter Capabilities aus Cap’n Proto ganz entfernt, und im Web-Standard kommt OAuth dem noch am nächsten.
Man könnte sich zwar einen
sturdyrefauf Basis von OAuth-Refresh-Tokens vorstellen, aber das wäre nichts, was sich plattformübergreifend nutzen ließe.Nach einem schnellen Blick scheint dieses System zu verlangen (oder zumindest zu fördern), dass Import-/Export-Tabellen oder Objektzustand serverseitig zustandsbehaftet gespeichert werden.
Bei traditionellem RPC kommen alle Aufrufe auf der obersten Ebene an, und jeder Aufruf übergibt Schlüssel usw., sodass es kein Problem ist, Requests auf mehrere Server zu verteilen — bei Cap’n Web scheint das anders zu sein.
Ich frage mich, ob man die Tabellen serialisieren und in einer Datenbank speichern kann, um die gleiche Art von Server-Verteilung zu ermöglichen, oder ob Server-Affinity bzw. Strukturen wie Durable Objects zwingend erforderlich sind.
Zustand wird nur innerhalb einer einzigen RPC-Session erhalten.
Bei Verwendung von WebSocket bleibt der Zustand so lange erhalten, wie die WebSocket-Verbindung besteht.
Wenn HTTP-Batch-Übertragung verwendet wird, ist die Session auf die Dauer eines einzelnen HTTP-Requests begrenzt, und alle Aufrufe darin werden auf einmal verarbeitet.
Cap'n Web muss also keinen Zustand über mehrere HTTP-Requests oder Verbindungen hinweg aufrechterhalten.
Man sollte allerdings Designs vermeiden, bei denen beim Abbruch einer Session alle Capabilities verloren gehen. Es muss jederzeit möglich sein, nach einem Reset der Verbindung die Capabilities wiederherzustellen.
Aus der Dokumentation lese ich heraus, dass die Affinity über WebSockets hergestellt wird.
HTTP-Batching bedeutet, dass alle Requests auf einmal gesendet werden und man dann auf die Antworten wartet.
Dadurch wird Load-Balancing schwieriger. Wenn es viele Chat-Clients gibt, könnten sich die Verbindungen auf bestimmte Server konzentrieren. Dann besteht die Gefahr, dass diese Server überlastet werden.
Auch Scale-in/Scale-out des Servers wird komplizierter. Wenn lang laufende Verbindungen bestehen und gleichzeitig mehrere Requests parallel verarbeitet werden, wird die Verwaltung sehr schwierig.
Noch ein Punkt: Wenn Clients dauerhaft nur Push-Events senden und nie Antworten entgegennehmen, muss der Server diese Antworten ständig im Speicher behalten; dadurch wären DDOS-Angriffe meiner Meinung nach leicht möglich.
Soweit ich mich an die Cap'n-Proto-Dokumentation erinnere, können Server und Clients sich gegenseitig Peer-Stubs übergeben.
Wenn Server C über Client B einen Stub erhält, der auf A erzeugt wurde, kann C A auch direkt aufrufen.
„RPC“ ist ursprünglich ein Programmierparadigma, bei dem Remote-Aufrufe so wirken sollen, als seien sie nicht von internen Funktionsaufrufen zu unterscheiden.
In der Praxis braucht man dafür natürlich ein Wire-Protokoll, Client-/Server-Bibliotheken usw.
Inzwischen hat sich die Sicht stark verändert, und Strukturen mit Funktionssignaturen ähnlich wie REST-Endpunkte sind verbreitet.
Durch Sprachfeatures wie
Future,Optionalusw. lassen sich Eigenschaften wie „dieser Vorgang kann verzögert sein“ oder „dieser Vorgang kann fehlschlagen“ klar kennzeichnen.Früher wurden all diese Eigenschaften bei RPC verborgen.
Ich frage mich, was genau damit gemeint ist. Asynchrone Programmierung gibt es in vielen Sprachen. Ich habe sie in JavaScript, C++, Python, Rust, C# und fast allen anderen verwendet.
Der Punkt ist wohl, dass frühe RPC-Systeme den aufrufenden Thread blockierten, während die Netzwerk-Anfrage lief — und das war wirklich ein schlechtes Design, weshalb Asynchronität heute selbstverständlich geworden ist.
Ich freue mich sehr darüber, dass Cap'n Web nicht nur an Cloudflare-Produkte gebunden ist, sondern auch eigenständig existiert.
Beim Lesen dieses Abschnitts in der Dokumentation kam mir eine Frage:
Ich glaube sogar, dass Cap'n Web Worker-RPC überholen könnte (tatsächlich ist es bei den Pipelining-Funktionen bereits voraus).
Die Struktur von Cap'n Web ist deutlich einfacher, daher werden neue Features wahrscheinlich sogar zuerst in Cap'n Web ausprobiert.