The site behind protobuf-net.dev: generate C# and
VB.NET from .proto schemas, reach protoc for the languages Google's compiler provides, and
pull apart raw protobuf payloads — with a schema to name the fields, or without one.
The site is a folder of static files. C#, VB.NET and payload decoding run in the browser through
WebAssembly, so a schema or payload pasted into either of those never leaves the machine it was
pasted on. The protoc targets are the exception and cannot be otherwise — protoc is a native
executable — so they are compiled by
protoc.protobuf-net.dev, and only when
the user picks one of those languages and presses Generate.
This replaces the older ASP.NET-hosted site that lived in the
protobuf-net repo under src/protogen.site.
| Layer | What it is |
|---|---|
src/ProtoGen.Wasm |
.NET 10 targeting net10.0-browser via Microsoft.NET.Sdk.WebAssembly — not Blazor. Exposes five [JSExport] methods and nothing else. |
src/ProtoGen.Wasm.Tests |
xunit over the same sources, on plain net10.0. Compiles them in rather than referencing the project: a browser-targeted assembly full of [JSExport] will not load in a test host. |
web |
Plain TypeScript + Vite. CodeMirror 6 for every editor. No UI framework. |
The schema work is done by the published protobuf-net.Reflection
package — the same parser and generators as the protogen command-line tool. Imports of
google/** and protobuf-net/** resolve from resources embedded in that package, so the common
cases need no network access.
The payload decomposition is local to this repo (WireWalker.cs). It deliberately does not use
ProtoReader: an analysis tool needs exact byte offsets for every component and partial results on
malformed input, neither of which a serializer's reader is built to give.
Total download is roughly 1.7 MB brotli-compressed, most of which is the .NET runtime, cached after first visit.
The decode view takes an optional .proto alongside the payload. With one, every field carries its
name and declared type, and the readings the wire format cannot choose between — string, bytes,
sub-message, packed scalars — collapse to the one the schema asked for. Both halves were already
here; the join is Decoder.cs, which is the only file that knows about schema text and payloads.
Three things are worth knowing about the design:
- A guessed root type is labelled as one. Nothing in a payload says which message it is, so
when the user has not said,
RootChooser.csworks it out — see below — and everything downstream carriesrootGuessed. An inferred type that happens to be wrong is the most misleading thing this view can produce, so it is never presented as though someone had chosen it. - The bytes win. Where the payload contradicts the schema — a wire type that does not match the
declared one, a
stringholding invalid UTF-8, a sub-message that will not parse — the field is flagged and then read as if there were no schema. Rendering a mismatch as the declared type would hide the exact bug the tool was opened to find. - A schema that fails to parse still helps. Its diagnostics travel with the decode and the fields it did understand are still named, rather than the whole thing falling back to guesswork.
The payload is read once as every message the schema declares, and the reads are compared. The scoring rule is the part that is easy to get wrong:
Only agreement counts. A message scores on how many fields it recognises, and fields it has
never heard of are not held against it. A payload carrying unknown fields is the normal condition
of protobuf — it is what a service that has moved on from the schema you have to hand sends, and
unknown fields are meant to survive a round trip untouched. Scoring on the share of fields
recognised would punish the right message for that, and would go further wrong on nesting: a
message that decomposes a sub-message which is itself newer than the schema would rank below one
that writes the same bytes off as opaque bytes, which is backwards.
Contradictions are different, and close to disqualifying. If the schema declares field 3 a
string and the payload wrote a varint there, this is not the message that wrote it — reusing a
field number with an incompatible type is the one thing protobuf's compatibility rules forbid,
which is what reserved exists to prevent. But a message that contradicts nothing because it
declares nothing the payload contains is the worst answer available, not the safest, so
recognising something and disagreeing about a corner still beats recognising nothing at all.
Ties fall to the order the schema declares its messages in, which is the closest thing a .proto
has to saying which message is the point of the file — and this is why RootCandidates is in
declaration order with the user's own file ahead of its imports, rather than alphabetised. A tie
broken that way is then reported rather than hidden: the UI marks it with a warning and names what
else fits, because at that point it is a coin toss and only the user can settle it.
Ranking is skipped for schemas with more than 48 messages or payloads over 128 KB, where it would cost more than the guess is worth; the first message declared is used and the note says so instead of implying anything was measured.
SchemaIndex.cs flattens the descriptor into the two lookups the walker actually needs — field
number to declared type, enum number to name — and SchemaSource.cs remembers the last schema it
parsed, so typing in the payload box does not reparse the schema beside it.
The JS boundary carries this as JSON: Decode(payload, requestJson) takes the schema and an
optional root type alongside the bytes — omitting the root type is what asks the engine to work one
out — and SchemaTypes(requestJson) lists the messages a schema declares, for the picker. Both go
through the same remembered parse.
Requires the .NET 10 SDK and Node 20+.
cd web
npm install
npm run dev # publishes the WASM project, then starts Vite on :5180
npm test # vitest, unit tests for reading pasted hex and base-64
npm run test:engine # dotnet test, unit tests for the wire walker and schema decoding
npm run build # wasm + typecheck + tests + bundle -> web/distnpm run build deliberately does not run the engine tests — it is the deploy path, and its job is
to produce web/dist. CI runs them as their own step, before it.
The protoc targets point at the live service by default. To develop against a local one, set
VITE_PROTOC_ENDPOINT — VITE_PROTOC_ENDPOINT=http://localhost:8787 npm run dev alongside
wrangler dev in the
service repo.
npm run wasm alone re-publishes the .NET side into web/public/_framework. That folder is
generated and git-ignored; it is copied verbatim rather than bundled, because the .NET boot process
resolves its own content-hashed filenames.
The engine version is shown in the site footer, so what is deployed is always checkable.
Most updates — parser fixes, better generated code — need one line:
# src/ProtoGen.Wasm/ProtoGen.Wasm.csproj
<PackageReference Include="protobuf-net.Reflection" Version="3.3.9" />Commit, push to main, done. Worth building locally first (cd web && npm run build), because a
new version can change generated output or surface fresh trim warnings — and TreatWarningsAsErrors
means a new warning fails the build rather than shipping quietly.
Two NuGet timing traps when bumping on release day: the package's dependencies
(protobuf-net.Core) index separately and can lag it by a few minutes, and a restore attempted
before everything indexed leaves a stale negative result in the local cache — NU1102 with a
confidently wrong "nearest version", even after the package is live. dotnet nuget locals http-cache --clear fixes the second.
When the language grows — editions was the big one — the bump alone makes parsing and codegen
work, but two things in web/src describe the language independently and need to keep up:
| File | What to add |
|---|---|
web/src/samples.ts |
a sample showing the new syntax (inline schema, or embedded naming a .proto that ships inside protobuf-net.Reflection) |
web/src/protobufMode.ts |
any new keywords, so the editor highlights them |
protobufMode.ts is a local copy of the trivial @codemirror/legacy-modes protobuf tokenizer —
vendored precisely because the upstream keyword list stops at early proto3 (it predates even
oneof and map), and a sample demonstrating new syntax looks broken when its keywords render
as plain identifiers.
CodeGenerator.Generate takes an options dictionary, so new switches need wiring through five
places. All mechanical, but missing one leaves an option that renders and does nothing:
| File | What to add |
|---|---|
src/ProtoGen.Wasm/Contracts.cs |
property on GenerateRequest |
src/ProtoGen.Wasm/Codegen.cs |
mapping in BuildOptions to the option key protobuf-net expects |
web/src/types.ts |
matching field on the GenerateRequest interface |
web/index.html |
the control, inside #schema-options, with name matching the property |
web/src/schema.ts |
read it in buildRequest |
Checkboxes are read with data.has(name), so if the name attribute matches the property, the
last step is a single line.
Check the option key against protobuf-net's own generator rather than guessing — the names on the
wire (listset, nullwrappers, compatlevel) do not always match the UI wording.
GitHub Actions builds on push to main and publishes web/dist to GitHub Pages
(.github/workflows/deploy.yml). There is no manual step: push, and roughly two minutes later it
is live.
index.html is served with Cache-Control: max-age=600, so a returning visitor can see the
previous version for up to ten minutes. Everything else is content-hashed and updates immediately.
Four details that matter for Pages:
_framework/dotnet.jsis cache-busted on purpose. Pages serves it withmax-age=14400and offers no way to say otherwise per path. It is the one file the .NET SDK does not content-hash, and it holds the hashed names of everything else — so a returning visitor with a four-hour-old copy asks for assemblies the current deploy no longer has, gets a 404 mid-boot, and sits on the loading overlay forever.vite.config.tshashes that file into__FRAMEWORK_ID__andwasm.tsloads it asdotnet.js?v=<id>, which changes exactly when something it loads does. This bites only on deploys that change the .NET side, which is why it stayed hidden for several web-only deploys before it did not.- The Vite build uses a relative base, so one artifact works both at the root of the custom
domain and under the
/protobuf-net.dev/subpath of the default*.github.ioURL. Anything that resolves the .NET runtime at load time must go throughimport.meta.env.BASE_URL; a leading slash silently breaks the subpath case. web/public/CNAMEnames the custom domain. With artifact-based deploys this file does not by itself change anything — the domain in the repo's Pages settings is what takes effect. Keep them in agreement.web/public/.nojekyllstops Jekyll stripping the_frameworkdirectory, which would otherwise be ignored for starting with an underscore. The artifact-based deploy does not run Jekyll, but the file guards against a future switch to branch-based publishing.
protobuf-net.dev is an apex (naked) domain, so it cannot use a CNAME record — that is only
valid for subdomains. Point it at GitHub's Pages addresses instead:
A protobuf-net.dev 185.199.108.153
A protobuf-net.dev 185.199.109.153
A protobuf-net.dev 185.199.110.153
A protobuf-net.dev 185.199.111.153
AAAA protobuf-net.dev 2606:50c0:8000::153
AAAA protobuf-net.dev 2606:50c0:8001::153
AAAA protobuf-net.dev 2606:50c0:8002::153
AAAA protobuf-net.dev 2606:50c0:8003::153
If the DNS host supports ALIAS/ANAME at the apex, a single record to protobuf-net.github.io
is preferable — it tracks GitHub's addresses if they ever change.
Optionally add CNAME www protobuf-net.github.io; GitHub redirects www to the apex.
.dev is on the HSTS preload list, so browsers will only ever load this over HTTPS. GitHub
provisions the certificate automatically once DNS resolves; until then the site is unreachable on
the custom domain, which is why the domain should be set in Pages settings after the records
are live.
C# and VB.NET locally; everything else through protoc. protobuf-net generates the first two,
in the browser. C++, Java, Kotlin, Objective-C, PHP, Python, Ruby and Google's own C# come from
protoc, which is a native executable and cannot run client-side, so they are compiled by
protoc.protobuf-net.dev — a Cloudflare
Worker in front of a container. That repository documents the service; what matters here is the
contract:
web/src/protoc.tsholds the target list, the endpoint and the response reader. Adding a target protoc already supports means one entry there and one in the service's own table.- The service answers with the same
GenerateResponseshape the WebAssembly path returns, so both render throughapplyResponseinweb/src/schema.tsand there is one code path for output, diagnostics and the editor gutter. - Nothing is sent while typing. Choosing a
protoctarget generates once, because choosing it is the decision to use it; after that the output is marked stale on edit and re-sent only when the user presses Generate. - The service carries the same
.protocorpus thatprotobuf-net.Reflectionembeds, so imports ofgoogle/**andprotobuf-net/**resolve identically on both paths. Nothing about imports is sent from here.
Compiling protoc itself to WebAssembly — which would have avoided the server altogether — was
investigated and parked. There is no maintained build: kwonoj/protobuf-wasm and
mjz20/protobuf_wasm patch the protobuf runtime for Emscripten and explicitly do not build the
compiler, and the one recent attempt at the compiler proper
(protobuf#20819) produces a protoc.js
that builds but fails code generation on path resolution. The real work is MEMFS plumbing for
--proto_path and generated outputs, unmaintained upstream, on top of a C++ binary that links
every language generator and would likely dwarf the .NET runtime. If that ever lands, the service
becomes unnecessary rather than merely unfortunate.
JavaScript and TypeScript are on neither list: protoc dropped its JavaScript generator in v21.
protobufjs and @bufbuild/protobuf generate both in pure JS, so that one belongs back in the
browser if it is ever worth having.
PHP output is not syntax-highlighted. @codemirror/legacy-modes has no PHP mode, and
@codemirror/lang-php brings the HTML, CSS and JavaScript parsers with it — too much weight for
colouring output that is read once and copied.
See docs/trimming.md for why the build suppresses IL2104.
Apache-2.0, matching protobuf-net.