From 2da61b4d27e2ed5ae8dcd4164eab826c000d58f4 Mon Sep 17 00:00:00 2001 From: Austin Harris Date: Thu, 24 Sep 2026 09:50:35 -0600 Subject: [PATCH 1/3] Documentation site built from the Markdown sources with Typst site/build.py renders README.md, docs/serializers.md, the package READMEs, the WebAssembly sample and the micro-benchmark READMEs through Typst's HTML export (cmarker) inside the page shell site/page.typ, then adds GitHub-compatible heading anchors, a table of contents, resolved relative links (pages, copied assets, GitHub for sources), Pygments highlighting, and copies the charts, data and benchmark explorer under benchmarks/charts/. The Pages workflow builds the site on every documentation change and fails on a page that does not compile. README: the documentation site is linked at the top and in the footer; the benchmark explorer moved to benchmarks/charts/explorer.html; the buy-me-a-coffee section is gone. --- .github/workflows/pages.yml | 30 +++-- .gitignore | 3 + README.md | 12 +- site/README.md | 45 +++++++ site/build.py | 242 ++++++++++++++++++++++++++++++++++++ site/page.typ | 81 ++++++++++++ site/style.css | 152 ++++++++++++++++++++++ 7 files changed, 550 insertions(+), 15 deletions(-) create mode 100644 site/README.md create mode 100644 site/build.py create mode 100644 site/page.typ create mode 100644 site/style.css diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6562457..7fbd676 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,8 +1,16 @@ -name: Benchmark explorer (GitHub Pages) +name: Documentation site (GitHub Pages) on: push: branches: [ master ] paths: + - "README.md" + - "docs/**" + - "site/**" + - "AustinHarris.JsonRpc.AspNetCore/README.md" + - "AustinHarris.JsonRpc.Newtonsoft/README.md" + - "AustinHarris.JsonRpc.SystemTextJson/README.md" + - "samples/WasmHost/README.md" + - "benchmarks/Micro/README.md" - "benchmarks/charts/**" - ".github/workflows/pages.yml" workflow_dispatch: @@ -24,17 +32,23 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v7 - # The committed outputs must match the data: a stale chart or explorer fails the deploy. + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - uses: typst-community/setup-typst@v5 + with: + typst-version: "0.14.2" + cache-dependency-path: site/page.typ + - run: pip install pygments==2.19.2 + # The committed charts must match the data: a stale chart or explorer fails the deploy. - name: Check the committed charts against the data run: python3 benchmarks/charts/render.py --check - - name: Stage the site - run: | - mkdir -p site - cp benchmarks/charts/explorer.html site/index.html - cp benchmarks/charts/*.svg benchmarks/charts/*.json site/ + # Every documentation page is rendered from its Markdown source; a page that fails to compile fails here. + - name: Build the site + run: python3 site/build.py --out site/_out - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 with: - path: site + path: site/_out - id: deployment uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index e8a3a67..3e1e203 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,6 @@ UpgradeLog*.XML # Python renderer caches __pycache__/ + +# Documentation site build output (site/build.py) +site/_out/ diff --git a/README.md b/README.md index 7e3c50c..f0639b2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ JSON-RPC.Net is a high performance [JSON-RPC 2.0](https://www.jsonrpc.org/specif Version 2.0 rebuilt the pipeline around UTF-8 bytes and made the JSON serializer pluggable. The core has no JSON library dependency; Json.NET and System.Text.Json ship as separate packages, and a built-in serializer needs neither. On one core it answers a small request in about 250 ns with no allocation; a Kestrel host on an 8-core desktop answers over 14 million requests per second over pipelined TCP. +Documentation site: [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RPC.NET/). + - [Packages](#packages) - [Requirements](#requirements) - [Installation](#installation) @@ -455,7 +457,7 @@ gRPC for .NET 2.84.0 with default settings apart from Kestrel's `MaxStreamsPerCo `--sweep` runs every one of those paths at 1, 2, 4, 8 and 16 client connections (gRPC: channels) and writes one JSON file per run; the chart above is five 2 s runs per point, the marker at the median and the whisker from the lowest to the highest run. It is a separate session from the tables: the WSL virtual machine was running and other work was active, so its absolute figures sit below the table rows (JSON-RPC.Net over TCP 10.9 M to 13.0 M at 16 connections against 13.7 M to 14.6 M in the table), and its gRPC unary figure runs higher (360 k to 404 k against 192 k to 198 k; the cause is not pinned down, and the table keeps the `--compare` figure). What the sweep adds is the shape: JSON-RPC.Net over TCP and batched HTTP climb almost linearly with connections, StreamJsonRpc gains 8 to 10× from one connection to sixteen, and gRPC's .NET client is flat from two channels on because it competes with the server for the same eight cores. -The [benchmark explorer](https://astn.github.io/JSON-RPC.NET/) is the same data as an interactive page: toggle series, hover or tab to a point for the exact low, median, high and every run, switch the axis between log and linear, and download the data. It is one self-contained HTML file, [benchmarks/charts/explorer.html](benchmarks/charts/explorer.html), so it also works saved to disk. +The [benchmark explorer](https://astn.github.io/JSON-RPC.NET/benchmarks/charts/explorer.html) is the same data as an interactive page: toggle series, hover or tab to a point for the exact low, median, high and every run, switch the axis between log and linear, and download the data. It is one self-contained HTML file, [benchmarks/charts/explorer.html](benchmarks/charts/explorer.html), so it also works saved to disk. ### WebAssembly: in the browser @@ -503,16 +505,12 @@ dotnet test AustinHarris.JsonRpcTestN The test suite runs its protocol cases once per serializer (built-in, Json.NET, System.Text.Json) plus the parser, dispatch, version-policy and Kestrel integration tests, on both `net8.0` and `net10.0`. Building a package project in Release produces its NuGet package in `bin/Release/`. The WebAssembly sample builds without the `wasm-tools` workload; add it for AOT. -## Do you like this? - -[![https://www.buymeacoffee.com/Ekati](https://cdn.buymeacoffee.com/buttons/default-blue.png)](https://www.buymeacoffee.com/Ekati) - ##### License JSON-RPC.net is licensed under The MIT License (MIT), check the [LICENSE](https://github.com/Astn/JSON-RPC.NET/blob/master/LICENSE) file for details. -##### Getting Started & Documentation +##### Documentation -Check our [documentation](https://github.com/Astn/JSON-RPC.NET/wiki). +This README and the package guides are also published as a site at [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RPC.NET/), built from the same files. ##### Old Project Site diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..cdb1b07 --- /dev/null +++ b/site/README.md @@ -0,0 +1,45 @@ +# Documentation site + +The pages at https://astn.github.io/JSON-RPC.NET/ are built from the Markdown files in this repository, so the README on GitHub, the package pages on NuGet and the site never drift apart. Nothing here is written twice. + +| Page | Source | +| --- | --- | +| Overview | `README.md` | +| Serializers | `docs/serializers.md` | +| ASP.NET Core hosting | `AustinHarris.JsonRpc.AspNetCore/README.md` | +| Json.NET serializer | `AustinHarris.JsonRpc.Newtonsoft/README.md` | +| System.Text.Json serializer | `AustinHarris.JsonRpc.SystemTextJson/README.md` | +| WebAssembly sample | `samples/WasmHost/README.md` | +| Micro-benchmarks | `benchmarks/Micro/README.md` | +| Benchmark explorer | `benchmarks/charts/explorer.html`, copied as is | + +## How it is built + +[Typst](https://typst.app) renders each Markdown file to HTML through the [cmarker](https://typst.app/universe/package/cmarker) package inside `page.typ`, which is also the page shell: head, top bar, page list, article, table of contents, footer. Typst's HTML export is still marked experimental upstream, so `build.py` finishes what it leaves out, in plain Python: + +- heading ids that match GitHub's anchors, so `#hosting-modes` works on both; +- a table of contents per page from the second- and third-level headings; +- relative links: another page becomes a site link; a file (a chart, the benchmark data) is copied under its repository path; a Markdown file or directory that is not a page links to GitHub; +- syntax highlighting with Pygments (light and dark); +- the charts, data and explorer copied under `benchmarks/charts/`. + +`style.css` is the whole design: system fonts, a three-column layout that collapses on narrow screens, colors that follow the system light or dark setting. + +## Build locally + +Install Typst 0.14 or newer and Pygments (`pip install pygments`), then: + +```bash +python site/build.py --out site/_out +python -m http.server --directory site/_out 8000 +``` + +The first build downloads the cmarker package into Typst's package cache. `site/_out` is ignored by git. + +## Deploy + +`.github/workflows/pages.yml` runs the same build on every push to `master` that touches a documentation file, a chart or this directory, and deploys `site/_out` with GitHub Pages. A page that fails to compile fails the deploy, as does a chart that no longer matches `benchmarks/charts/benchmarks.json`. + +## Add a page + +Add a row to `PAGES` in `build.py` (output file, source Markdown, title, label, group, one-line description). Links from other Markdown files to that source resolve to the new page automatically. diff --git a/site/build.py b/site/build.py new file mode 100644 index 0000000..e8e4f67 --- /dev/null +++ b/site/build.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Build the documentation site for GitHub Pages. + +Each page is a Markdown file that already lives in the repository (the README, the serializer +guide, the package READMEs, the sample and benchmark READMEs). Typst's HTML export renders it +through the cmarker package inside site/page.typ; this script drives the compiler and finishes +what the export leaves out: + +- GitHub-compatible heading ids, so the anchors that work on github.com work on the site; +- a table of contents per page; +- relative links: a link to another page becomes a site link, a link to a file becomes a copy + of that file under its repository path, a link to a Markdown file or a directory that is not + a page goes to GitHub; +- syntax highlighting with Pygments; +- the benchmark charts, data and the interactive explorer, copied under benchmarks/charts/. + + python site/build.py --out site/_out + python -m http.server --directory site/_out 8000 + +The Pages workflow runs the same command; a page that fails to compile fails the deploy. +""" +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_by_name +from pygments.util import ClassNotFound + +ROOT = Path(__file__).resolve().parent.parent +REPO = "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/Astn/JSON-RPC.NET" +BRANCH = "master" +TYPST_VERSION = "0.14" + +# (output file, source Markdown, page title, label in the page list, group, description) +PAGES = [ + ("index.html", "README.md", "JSON-RPC.NET", "Overview", "Guide", + "A high performance JSON-RPC 2.0 server for .NET: bytes in, bytes out, pluggable serializers, Kestrel hosting."), + ("serializers.html", "docs/serializers.md", "Serializers", "Serializers", "Guide", + "How the built-in, Json.NET and System.Text.Json serializers differ, and how to configure or write one."), + ("aspnetcore.html", "AustinHarris.JsonRpc.AspNetCore/README.md", "ASP.NET Core hosting", "ASP.NET Core hosting", "Packages", + "Host JSON-RPC.NET in Kestrel: HTTP endpoint, raw TCP, Unix socket and named pipe connections, DI registration."), + ("newtonsoft.html", "AustinHarris.JsonRpc.Newtonsoft/README.md", "Json.NET serializer", "Json.NET serializer", "Packages", + "The Json.NET serializer package: settings, attributes, converters and lenient input."), + ("systemtextjson.html", "AustinHarris.JsonRpc.SystemTextJson/README.md", "System.Text.Json serializer", "System.Text.Json serializer", "Packages", + "The System.Text.Json serializer package: options, source generation and UTF-8 readers on the request bytes."), + ("wasm.html", "samples/WasmHost/README.md", "WebAssembly sample", "WebAssembly sample", "More", + "The server running inside the browser as Blazor WebAssembly, with a benchmark against plain interop."), + ("micro.html", "benchmarks/Micro/README.md", "Micro-benchmarks", "Micro-benchmarks", "More", + "BenchmarkDotNet timings of one request per shape, with allocation columns."), +] +EXPLORER = ("benchmarks/charts/explorer.html", "Benchmark explorer", "More") +CHARTS = ROOT / "benchmarks" / "charts" + +HEADING = re.compile(r"(.*?)", re.DOTALL) +CODE = re.compile(r"
(.*?)
", re.DOTALL) +ATTR = re.compile(r"\b(href|src|srcset)=\"([^\"]*)\"") +TAGS = re.compile(r"<[^>]+>") +LEXERS = {"csharp": "csharp", "cs": "csharp", "bash": "bash", "sh": "bash", "powershell": "powershell", + "json": "json", "xml": "xml", "html": "html", "csv": "text", "text": "text", "": "text", + "proto": "protobuf", "yaml": "yaml", "typ": "text"} + + +def slug(text: str) -> str: + """GitHub's heading anchor: lowercase, drop punctuation, spaces to hyphens.""" + text = html.unescape(TAGS.sub("", text)).strip().lower() + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +def run_typst(source: str, title: str, description: str, pages_json: str, out: Path) -> None: + cmd = ["typst", "compile", "--features", "html", "--format", "html", "--root", str(ROOT), + "--input", f"source={source}", "--input", f"title={title}", + "--input", f"description={description}", "--input", f"pages={pages_json}", + str(ROOT / "site" / "page.typ"), str(out)] + result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") + # The export prints a standing "under development" warning; only real diagnostics matter. + noise = ("html export is under active development", "= hint:", "its behaviour may change", "do not rely on this", + "see https://github.com/typst/typst/issues/5512") + lines = [l for l in result.stderr.splitlines() if l.strip() and not any(n in l for n in noise)] + if result.returncode != 0: + sys.exit(f"typst failed on {source}:\n" + "\n".join(lines)) + for line in lines: + print(f" typst: {line}") + + +def highlight_code(page: str) -> str: + formatter = HtmlFormatter(nowrap=True) + + def repl(m: re.Match) -> str: + lang, body = m.group(1), html.unescape(m.group(2)) + name = LEXERS.get(lang.lower(), lang.lower()) + try: + lexer = get_lexer_by_name(name, stripnl=False) + except ClassNotFound: + lexer = get_lexer_by_name("text", stripnl=False) + code = highlight(body, lexer, formatter).rstrip("\n") + return f'
{code}
' + + return CODE.sub(repl, page) + + +def number_headings(page: str) -> tuple[str, list[tuple[int, str, str]]]: + """Shift levels (Typst emits `#` as h2), add GitHub ids and anchor links, collect the TOC.""" + seen: dict[str, int] = {} + toc: list[tuple[int, str, str]] = [] + + def repl(m: re.Match) -> str: + level = int(m.group(1)) - 1 + text = m.group(2).strip() + base = slug(text) or "section" + n = seen.get(base, 0) + seen[base] = n + 1 + anchor = base if n == 0 else f"{base}-{n}" + if 2 <= level <= 3: + toc.append((level, anchor, html.unescape(TAGS.sub("", text)))) + return (f'{text}' + f'#') + + return HEADING.sub(repl, page), toc + + +def toc_html(toc: list[tuple[int, str, str]]) -> str: + if len(toc) < 2: + return "" + items = [f'{html.escape(text)}' for level, anchor, text in toc] + return '

On this page

' + "".join(items) + + +def resolve_links(page: str, source: str, out_dir: Path, page_map: dict[str, str], anchors: set[str]) -> str: + """Rewrite relative hrefs and srcs; copy referenced files under their repository path.""" + source_dir = (ROOT / source).parent + problems: list[str] = [] + + def target(value: str) -> str: + if value.startswith(("http://", "https://", "mailto:", "//", "data:")): + return value + if value.startswith("#"): + if value[1:] not in anchors: + problems.append(f"anchor {value} not found") + return value + path_part, _, fragment = value.partition("#") + rel = os.path.normpath(os.path.join(source_dir, path_part)) + try: + repo_rel = Path(rel).resolve().relative_to(ROOT).as_posix() + except ValueError: + problems.append(f"link outside the repository: {value}") + return value + suffix = f"#{fragment}" if fragment else "" + if repo_rel in page_map: + return page_map[repo_rel] + suffix + full = ROOT / repo_rel + if full.is_dir(): + return f"{REPO}/tree/{BRANCH}/{repo_rel}" + if not full.is_file(): + problems.append(f"missing file: {value}") + return value + if full.suffix.lower() in (".md", ".cs", ".csproj", ".py", ".proto", ".yml", ".yaml", ".sln", ".razor", ".props", + ".html", ".js", ".css", ".txt"): + return f"{REPO}/blob/{BRANCH}/{repo_rel}{suffix}" + dest = out_dir / repo_rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(full, dest) + return repo_rel + suffix + + def repl(m: re.Match) -> str: + attr, value = m.group(1), m.group(2) + if attr == "srcset": + parts = [p.strip() for p in value.split(",")] + new = ", ".join(" ".join([target(p.split()[0])] + p.split()[1:]) for p in parts if p) + return f'{attr}="{new}"' + return f'{attr}="{target(value)}"' + + # Only the rendered article carries repository-relative links; the shell's own links stay. + start = page.index('
') + end = page.index("
", start) + page = page[:start] + ATTR.sub(repl, page[start:end]) + page[end:] + for p in problems: + print(f" {source}: {p}") + return page + + +def build(out_dir: Path) -> None: + if shutil.which("typst") is None: + sys.exit("typst is not on PATH; install Typst 0.14 or newer (https://github.com/typst/typst/releases)") + out_dir.mkdir(parents=True, exist_ok=True) + page_map = {source: output for output, source, *_ in PAGES} + nav_entries = [(output, label, group) for output, _, _, label, group, _ in PAGES] + nav_entries.append(EXPLORER) + + for output, source, title, label, group, description in PAGES: + print(f"{source} -> {output}") + pages_json = json.dumps([ + {"href": href, "label": lbl, "current": href == output} + for href, lbl, _ in nav_entries + ]) + raw = out_dir / (output + ".typst.html") + run_typst(source, title, description, pages_json, raw) + page = raw.read_text(encoding="utf-8") + raw.unlink() + page, toc = number_headings(page) + anchors = {anchor for _, anchor, _ in toc} | { + m.group(1) for m in re.finditer(r'', + f'') + page = highlight_code(page) + page = resolve_links(page, source, out_dir, page_map, anchors) + (out_dir / output).write_text(page, encoding="utf-8") + + shutil.copyfile(ROOT / "site" / "style.css", out_dir / "style.css") + light = HtmlFormatter(style="default").get_style_defs("pre code") + dark = HtmlFormatter(style="github-dark").get_style_defs("pre code") + (out_dir / "highlight.css").write_text( + f"{light}\n@media (prefers-color-scheme: dark) {{\n{dark}\n}}\n", encoding="utf-8") + + charts_out = out_dir / "benchmarks" / "charts" + charts_out.mkdir(parents=True, exist_ok=True) + for f in CHARTS.iterdir(): + if f.suffix in (".svg", ".json") or f.name == "explorer.html": + shutil.copyfile(f, charts_out / f.name) + (out_dir / ".nojekyll").write_text("", encoding="utf-8") + print(f"site written to {out_dir}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--out", default=str(ROOT / "site" / "_out"), help="output directory (default site/_out)") + args = parser.parse_args() + build(Path(args.out).resolve()) + + +if __name__ == "__main__": + main() diff --git a/site/page.typ b/site/page.typ new file mode 100644 index 0000000..638ae17 --- /dev/null +++ b/site/page.typ @@ -0,0 +1,81 @@ +// One documentation page. Typst's HTML export renders a Markdown file (through the cmarker +// package) inside this shell; site/build.py drives the compiler and finishes the page +// (heading anchors, table of contents, link resolution, syntax highlighting). +// +// Inputs (all strings, passed with `--input name=value`): +// source repo-relative path of the Markdown file to render +// title the page title +// description one sentence for the meta description +// pages JSON array of { "href", "label", "current" } for the page list +#import "@preview/cmarker:0.1.6" + +#let source = sys.inputs.at("source") +#let title = sys.inputs.at("title") +#let description = sys.inputs.at("description", default: "") +#let pages = json(bytes(sys.inputs.at("pages", default: "[]"))) +#let repo = "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/Astn/JSON-RPC.NET" + +#set document(title: title) + +// Images become plain elements; Typst would otherwise try to load the file. +#let himg(src, alt: none) = html.elem("img", attrs: ( + src: src, + alt: if alt == none { "" } else { alt }, +)) + +// Code blocks keep their text and language; build.py highlights them. +#show raw.where(block: true): it => html.elem( + "pre", + html.elem("code", attrs: (class: "language-" + (if it.lang == none { "text" } else { it.lang })), it.text), +) + +#let content = cmarker.render( + read("/" + source), + scope: (image: himg), + html: ( + img: ("void", (attrs) => himg(attrs.at("src", default: ""), alt: attrs.at("alt", default: ""))), + picture: (attrs, body) => html.elem("picture", body), + source: ("void", (attrs) => html.elem("source", attrs: attrs)), + ), +) + +#let page-link(p) = { + let attrs = (href: p.href) + if p.current { attrs.insert("class", "current"); attrs.insert("aria-current", "page") } + html.elem("a", attrs: attrs, p.label) +} + +#html.elem("html", attrs: (lang: "en"), { + html.elem("head", { + html.elem("meta", attrs: (charset: "utf-8")) + html.elem("meta", attrs: (name: "viewport", content: "width=device-width, initial-scale=1")) + html.elem("meta", attrs: (name: "color-scheme", content: "light dark")) + html.elem("title", title) + if description != "" { html.elem("meta", attrs: (name: "description", content: description)) } + html.elem("link", attrs: (rel: "stylesheet", href: "style.css")) + html.elem("link", attrs: (rel: "stylesheet", href: "highlight.css")) + }) + html.elem("body", { + html.elem("a", attrs: (class: "skip", href: "#content"), "Skip to content") + html.elem("header", attrs: (class: "top"), { + html.elem("a", attrs: (class: "brand", href: "index.html"), "JSON-RPC.NET") + html.elem("nav", attrs: ("aria-label": "Project"), { + html.elem("a", attrs: (href: repo), "GitHub") + html.elem("a", attrs: (href: "https://www.nuget.org/packages/AustinHarris.JsonRpc"), "NuGet") + html.elem("a", attrs: (href: repo + "/releases"), "Releases") + }) + }) + html.elem("div", attrs: (class: "layout"), { + html.elem("nav", attrs: (class: "pages", "aria-label": "Pages"), for p in pages { page-link(p) }) + html.elem("main", attrs: (id: "content"), content) + html.elem("nav", attrs: (class: "toc", id: "toc", "aria-label": "On this page"), none) + }) + html.elem("footer", { + [JSON-RPC.NET is MIT licensed. Source, issues and releases live on ] + html.elem("a", attrs: (href: repo), "GitHub") + [. This page is built from ] + html.elem("a", attrs: (href: repo + "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/blob/master/" + source), source) + [.] + }) + }) +}) diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..53020fb --- /dev/null +++ b/site/style.css @@ -0,0 +1,152 @@ +/* JSON-RPC.NET documentation site. Plain CSS, no framework; light and dark follow the system. */ + +:root { + --bg: #ffffff; + --bg-soft: #f6f8fa; + --fg: #1f2328; + --fg-muted: #59636e; + --line: #d1d9e0; + --accent: #0969da; + --accent-soft: #ddf4ff; + --code-bg: #f6f8fa; + --mark: #fff8c5; + --max: 78ch; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --bg-soft: #161b22; + --fg: #e6edf3; + --fg-muted: #9198a1; + --line: #30363d; + --accent: #4493f8; + --accent-soft: #121d2f; + --code-bg: #161b22; + --mark: #3a2f00; + } +} + +* { box-sizing: border-box; } + +html { scroll-padding-top: 4.5rem; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 16px/1.6 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + -webkit-text-size-adjust: 100%; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.skip { + position: absolute; left: -999px; top: 0; padding: .5rem 1rem; + background: var(--accent); color: #fff; z-index: 10; +} +.skip:focus { left: 1rem; } + +/* Top bar */ +.top { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 1.5rem; + padding: 0 1.25rem; height: 3.25rem; + background: var(--bg-soft); + border-bottom: 1px solid var(--line); +} +.top .brand { font-weight: 600; color: var(--fg); font-size: 1.05rem; } +.top nav { display: flex; gap: 1.25rem; margin-left: auto; } +.top nav a { color: var(--fg-muted); } +.top nav a:hover { color: var(--fg); text-decoration: none; } + +/* Three columns on wide screens: page list, content, on-page contents */ +.layout { + display: grid; + grid-template-columns: 15rem minmax(0, var(--max)) 16rem; + justify-content: center; + gap: 3rem; + padding: 2rem 1.25rem 4rem; +} + +nav.pages, nav.toc { + position: sticky; top: 4.25rem; + align-self: start; + max-height: calc(100vh - 5rem); + overflow: auto; + font-size: .9rem; +} +nav.pages a, nav.toc a { display: block; padding: .25rem .5rem; color: var(--fg-muted); border-left: 2px solid transparent; } +nav.pages a:hover, nav.toc a:hover { color: var(--fg); text-decoration: none; } +nav.pages a.current { color: var(--accent); border-left-color: var(--accent); background: var(--accent-soft); } +nav.pages .group { margin: 1rem 0 .25rem .5rem; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--fg-muted); } +nav.toc .toc-title { margin: 0 0 .25rem .5rem; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--fg-muted); } +nav.toc a.l3 { padding-left: 1.25rem; font-size: .85rem; } +nav.toc a.active { color: var(--accent); border-left-color: var(--accent); } + +@media (max-width: 1180px) { + .layout { grid-template-columns: 14rem minmax(0, 1fr); } + nav.toc { display: none; } +} +@media (max-width: 800px) { + .layout { display: block; padding: 1.25rem 1rem 3rem; } + nav.pages { position: static; max-height: none; display: flex; flex-wrap: wrap; gap: .25rem; margin-bottom: 1.5rem; } + nav.pages a { border: 1px solid var(--line); border-radius: 999px; padding: .2rem .7rem; } + nav.pages a.current { border-color: var(--accent); } + nav.pages .group { display: none; } + .top { gap: 1rem; padding: 0 1rem; } + .top nav { gap: .9rem; font-size: .9rem; } +} + +/* Content */ +main { min-width: 0; } +main > :first-child { margin-top: 0; } + +h1, h2, h3, h4, h5, h6 { line-height: 1.25; font-weight: 600; scroll-margin-top: 4.5rem; } +h1 { font-size: 2rem; margin: 0 0 1rem; padding-bottom: .3em; border-bottom: 1px solid var(--line); } +h2 { font-size: 1.5rem; margin: 2.25rem 0 1rem; padding-bottom: .3em; border-bottom: 1px solid var(--line); } +h3 { font-size: 1.2rem; margin: 1.75rem 0 .75rem; } +h4 { font-size: 1rem; margin: 1.5rem 0 .5rem; } +h5, h6 { font-size: .9rem; margin: 1.25rem 0 .5rem; color: var(--fg-muted); } +.anchor { margin-left: .4em; color: var(--line); font-weight: 400; opacity: 0; } +h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor { opacity: 1; } +.anchor:hover { color: var(--accent); text-decoration: none; } + +p, ul, ol, table, pre, blockquote, picture { margin: 0 0 1rem; } +li { margin: .2rem 0; } +li > p { margin-bottom: .4rem; } +blockquote { border-left: 3px solid var(--line); color: var(--fg-muted); padding: 0 1rem; } +hr { border: 0; border-top: 1px solid var(--line); margin: 2rem 0; } +img { max-width: 100%; height: auto; } +picture { display: block; } + +code, pre { + font: .875em/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; +} +code { background: var(--code-bg); border-radius: 4px; padding: .15em .35em; } +pre { + background: var(--code-bg); + border: 1px solid var(--line); + border-radius: 6px; + padding: .9rem 1rem; + overflow: auto; + line-height: 1.45; +} +pre code { background: none; padding: 0; font-size: .875rem; } + +table { border-collapse: collapse; width: 100%; display: block; overflow: auto; font-size: .95rem; } +th, td { border: 1px solid var(--line); padding: .4rem .75rem; text-align: left; vertical-align: top; } +th { background: var(--bg-soft); font-weight: 600; } +tr:nth-child(even) td { background: color-mix(in srgb, var(--bg-soft) 50%, var(--bg)); } +td code, th code { white-space: nowrap; } + +mark { background: var(--mark); color: inherit; } + +footer { + border-top: 1px solid var(--line); + color: var(--fg-muted); + font-size: .85rem; + padding: 1.5rem 1.25rem 2.5rem; + text-align: center; +} From dcdb30392f16673c2d3a097ad4a917317fc505db Mon Sep 17 00:00:00 2001 From: Austin Harris Date: Thu, 24 Sep 2026 09:57:33 -0600 Subject: [PATCH 2/3] Site: group the page list and add an inline favicon --- site/build.py | 4 ++-- site/page.typ | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/site/build.py b/site/build.py index e8e4f67..c769182 100644 --- a/site/build.py +++ b/site/build.py @@ -200,8 +200,8 @@ def build(out_dir: Path) -> None: for output, source, title, label, group, description in PAGES: print(f"{source} -> {output}") pages_json = json.dumps([ - {"href": href, "label": lbl, "current": href == output} - for href, lbl, _ in nav_entries + {"href": href, "label": lbl, "group": group, "current": href == output} + for href, lbl, group in nav_entries ]) raw = out_dir / (output + ".typst.html") run_typst(source, title, description, pages_json, raw) diff --git a/site/page.typ b/site/page.typ index 638ae17..54f11ae 100644 --- a/site/page.typ +++ b/site/page.typ @@ -45,6 +45,21 @@ html.elem("a", attrs: attrs, p.label) } +// The page list, with a small heading whenever the group changes. +#let page-list(pages) = { + let group = none + for p in pages { + if p.group != group { + group = p.group + html.elem("p", attrs: (class: "group"), group) + } + page-link(p) + } +} + +// A small favicon inline: the initials on a rounded square, no request needed. +#let favicon = "data:image/svg+xml," + "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230969da'/%3E%3Ctext x='16' y='21' font-family='Arial,sans-serif' font-size='14' font-weight='700' fill='white' text-anchor='middle'%3ERPC%3C/text%3E%3C/svg%3E" + #html.elem("html", attrs: (lang: "en"), { html.elem("head", { html.elem("meta", attrs: (charset: "utf-8")) @@ -54,6 +69,7 @@ if description != "" { html.elem("meta", attrs: (name: "description", content: description)) } html.elem("link", attrs: (rel: "stylesheet", href: "style.css")) html.elem("link", attrs: (rel: "stylesheet", href: "highlight.css")) + html.elem("link", attrs: (rel: "icon", href: favicon)) }) html.elem("body", { html.elem("a", attrs: (class: "skip", href: "#content"), "Skip to content") @@ -66,7 +82,7 @@ }) }) html.elem("div", attrs: (class: "layout"), { - html.elem("nav", attrs: (class: "pages", "aria-label": "Pages"), for p in pages { page-link(p) }) + html.elem("nav", attrs: (class: "pages", "aria-label": "Pages"), page-list(pages)) html.elem("main", attrs: (id: "content"), content) html.elem("nav", attrs: (class: "toc", id: "toc", "aria-label": "On this page"), none) }) From 297c177712ee1ac65b450e963b5a262868d2a253 Mon Sep 17 00:00:00 2001 From: Austin Harris Date: Thu, 24 Sep 2026 10:18:39 -0600 Subject: [PATCH 3/3] Editorial pass over the documentation Two independent editorial reviews of the README, the serializer guide, the package READMEs, the WebAssembly sample and the micro-benchmark page, reconciled into one list and applied here. Factual corrections: - conversion failures are -32602 with structured data, not -32603 (Newtonsoft and System.Text.Json READMEs) - NaN and the infinities are written as quoted strings, not bare symbols - the session-less handler setters on Config set the default session's handler only; the Configuration table now shows the scope of each setting - Process(json, null) is ambiguous and ProcessAsync(json, null) binds json as the session id; the upgrade notes say so and show the working forms - the StreamJsonRpc comparison did not use the same JSON library - exception disclosure: the type name and message are sent by default - AddJsonRpcService() registers a singleton resolved once at startup; scoped dependencies and per-request services are explained - unknown session ids create sessions that persist; session selectors must map to a fixed set of ids - the classic ASP.NET package targets .NET Framework 4.0 and cannot be combined with the 2.0 core - "Bytes beat strings" holds under the interpreter only; the WebAssembly page now cites the README's 217 ns figure and derives its ratios from it - byte[] arguments need AsSpan() on C# 12; the quick start now compiles as pasted (verified on net8.0 and net10.0) Structure: - README reordered around the reader's path: Getting started keeps the class example only; delegates and interfaces move to Defining methods; Errors, Asynchronous methods and cancellation, and Sessions and context are top-level sections; new Security and Versioning and support sections; the benchmark section opens with a summary table and its history and chart pipeline move out of the reader's way - the upgrade list is grouped by impact: build breaks, wire changes, server behaviour - the raw-connection samples listen on loopback and state what a raw connection does not have (auth, middleware, lenient JSON) - the AspNetCore README gains install, targets, a services-and-lifetime section, an async section, the per-endpoint MapJsonRpc overload and a scope column in the options table - docs/serializers.md: MaxDepth in the contract, full byte signatures, the core/serializer split as two lists instead of a paired table, the thread-safety text split for users and custom-reader authors, and the 1.x appendix reduced to serializer-specific items with a link to the canonical upgrade list - one product spelling (JSON-RPC.Net), one name per concept, stale footer and CodePlex references removed render.py --check still passes; every published benchmark figure is unchanged. --- AustinHarris.JsonRpc.AspNetCore/README.md | 115 ++++- AustinHarris.JsonRpc.Newtonsoft/README.md | 37 +- AustinHarris.JsonRpc.SystemTextJson/README.md | 40 +- README.md | 483 ++++++++++++------ benchmarks/Micro/README.md | 8 +- docs/serializers.md | 134 +++-- samples/WasmHost/README.md | 35 +- 7 files changed, 565 insertions(+), 287 deletions(-) diff --git a/AustinHarris.JsonRpc.AspNetCore/README.md b/AustinHarris.JsonRpc.AspNetCore/README.md index 2ccb3b3..fb4bb12 100644 --- a/AustinHarris.JsonRpc.AspNetCore/README.md +++ b/AustinHarris.JsonRpc.AspNetCore/README.md @@ -5,6 +5,15 @@ UTF-8 bytes, so the HTTP endpoint reads the body with `PipeReader` and writes st `Response.BodyWriter`; nothing is turned into a string on the way through. A `ConnectionHandler` does the same for JSON-RPC over a raw Kestrel connection (TCP, Unix socket, named pipe). +## Install + +``` +dotnet add package AustinHarris.JsonRpc.AspNetCore +``` + +Targets `net8.0` and `net10.0`; depends on the `AustinHarris.JsonRpc` core package and the ASP.NET Core shared +framework. + ## HTTP endpoint ```csharp @@ -31,15 +40,16 @@ public class CalculatorService public CalculatorService(ILogger log) => _log = log; [JsonRpcMethod] - public double add(double l, double r) => l + r; + public double add(double l, double r) + { + _log.LogDebug("add {L} {R}", l, r); + return l + r; + } } ``` `POST /rpc` with a request or a batch answers `200 application/json`; a notification answers `204`. Inside a method `JsonRpcContext.Current().Value` is the `HttpContext` (override with `ContextFactory`). -Classes deriving from `JsonRpcService` still bind themselves; `AddJsonRpcService()` is for classes that -take constructor dependencies, and `AddJsonRpcServicesFromAssembly(typeof(Program).Assembly)` registers every -class that declares a `[JsonRpcMethod]`, MVC controllers included. Because it is an ordinary endpoint, `RequireAuthorization()`, rate limiting, output caching and the rest of the middleware pipeline compose with it: @@ -48,35 +58,90 @@ the middleware pipeline compose with it: app.MapJsonRpc("/rpc").RequireAuthorization("api"); ``` -## Raw connection (TCP) +`MapJsonRpc` adds no authorization, TLS requirement, rate limit or request deadline by itself; apply those +policies explicitly. `MaxRequestBytes` limits the HTTP body, but there is no batch-count or response-size limit. +Keep `Config.IncludeExceptionDetails` off for untrusted clients; the default still sends an unhandled exception's +CLR type name and message, see [Exception disclosure](https://github.com/Astn/JSON-RPC.NET#exception-disclosure) +in the main README. + +`MapJsonRpc(pattern = "/jsonrpc", options = null)` uses the options from `AddJsonRpc` unless you pass your own, +so two endpoints can serve two sessions, for example a strict API next to a lenient one for older clients: + +```csharp +app.MapJsonRpc("/rpc"); +app.MapJsonRpc("/legacy", new JsonRpcOptions { SessionId = "legacy-clients", Serializer = new NewtonsoftJsonRpcSerializer() }); +``` + +## Services and lifetime + +`AddJsonRpcService()` registers `T` as a singleton unless `T` is already registered. When the host starts, +each registered service is resolved once from the root container and bound; that one instance then serves every +HTTP request and every raw connection, concurrently. So `T` and its dependencies must be thread-safe, and `T` +cannot take scoped dependencies such as an EF Core `DbContext`: with scope validation on, the host fails at +startup; with it off, the dependency leaks. For per-request services, resolve them inside the method from +`((HttpContext)Handler.RpcContext()).RequestServices` on HTTP; a raw connection's context is the +`ConnectionContext`, which has no request scope. Do not inject request-scoped state into a service; read +per-request data from the context instead. + +`AddJsonRpcServicesFromAssembly(assembly)` does the same for every non-abstract class in the assembly that +declares a `[JsonRpcMethod]`. Private methods count, so the attribute is the whole access list, and an MVC +controller that carries it becomes a singleton too. + +A class deriving from `JsonRpcService` binds itself to the default session in its constructor. Registering it here +as well is harmless when the effective session is the default. With `SessionId` set, the host binds it to that +session in addition, so it stays reachable on the default session too. + +## Raw connection (TCP, Unix socket, named pipe) ```csharp builder.WebHost.ConfigureKestrel(k => { - k.ListenAnyIP(9000, l => l.UseConnectionHandler()); + k.ListenLocalhost(9000, l => l.UseConnectionHandler()); + // k.ListenUnixSocket("/tmp/rpc.sock", l => l.UseConnectionHandler()); + // k.ListenNamedPipe("rpc", l => l.UseConnectionHandler()); }); ``` -Clients write JSON documents back to back (a newline between them is fine); each document is answered in -order on the same connection, notifications produce nothing. The `ConnectionContext` is the RPC context. +Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses +in the same order, also back to back with no newline or `Content-Length` prefix, so the client must parse one +complete JSON value at a time. Notifications produce nothing. The `ConnectionContext` is the RPC context. + +The framer accepts strict JSON only, even with the Json.NET serializer or a lenient `JsmnSerializer` selected. +A document larger than `MaxRequestBytes` aborts the connection. Documents on one connection are processed one at +a time, in order; separate connections run concurrently. + +A raw connection does not pass through the HTTP middleware pipeline, so it has no authentication, authorisation +or rate limiting. Listen on loopback or a Unix socket, or configure transport security, authentication and +connection limits at the Kestrel listener or in a surrounding protocol. + +## Async methods + +Set `EnableAsyncMethods = true` to serve `Task` and `ValueTask` methods through `ProcessAsync`. With it off (the +default), requests are processed synchronously and an async method is answered with `-32603` without being +invoked. + +- **HTTP:** the call is cancelled when the client disconnects (`HttpContext.RequestAborted`). Notifications are + awaited and still answer `204`. The body reader stays leased until the invocation finishes. +- **Raw connections:** documents are processed one at a time, in order. Replies already finished are flushed + before the connection waits on a slow method. When the connection closes, the running method is waited for and + its response discarded. -With `EnableAsyncMethods = true`, HTTP awaits `ProcessAsync` with `HttpContext.RequestAborted`; -the body reader remains leased until invocation finishes. Raw connections await each framed document -before starting the next and flush earlier completed replies before waiting for a slow document. -Notifications are awaited and keep the same HTTP status rules. Connection cancellation is cooperative: -the processor waits for the running method to terminate before releasing input and discards its staged response. -Mark a `CancellationToken` parameter with `[JsonRpcCancellation]` to receive that token. -The default mode preserves synchronous processing and rejects async methods at call time. +A method receives the token by declaring a `[JsonRpcCancellation] CancellationToken` parameter; see +[Asynchronous methods and cancellation](https://github.com/Astn/JSON-RPC.NET#asynchronous-methods-and-cancellation) +in the main README. ## Options -| Option | Default | Meaning | -|---|---|---| -| `EnableAsyncMethods` | false | use ProcessAsync for Task/ValueTask methods, with host cancellation | -| `SessionId` | default session | which session's methods answer | -| `SessionSelector` | null | pick the session per HTTP request | -| `Serializer` | session, then `Config.Serializer` | serializer for this host | -| `ContextFactory` | `HttpContext` | what `JsonRpcContext.Current()` returns | -| `MaxRequestBytes` | 4 MB | larger bodies get 413 (HTTP) or abort the connection | -| `ResponseContentType` | `application/json` | | -| `NoContentForNotifications` | true | 204 for notifications, otherwise 200 with an empty body | +| Option | Default | Scope | Meaning | +|---|---|---|---| +| `EnableAsyncMethods` | false | HTTP and raw | use `ProcessAsync` for `Task`/`ValueTask` methods, with host cancellation | +| `SessionId` | default session | HTTP and raw | which session's methods answer | +| `SessionSelector` | null | HTTP | pick the session per request from the `HttpContext`; it must map to a fixed set of ids, because an unknown id creates a session that persists | +| `Serializer` | session, then `Config.Serializer` | HTTP and raw | serializer for this host | +| `ContextFactory` | `HttpContext` | HTTP | what `JsonRpcContext.Current()` returns | +| `MaxRequestBytes` | 4 MB | HTTP body, or one raw document | larger bodies get 413; a larger raw document aborts the connection | +| `ResponseContentType` | `application/json` | HTTP | | +| `NoContentForNotifications` | true | HTTP | 204 for notifications, otherwise 200 with an empty body | + +For raw connections the RPC context is always the `ConnectionContext`; `SessionSelector`, `ContextFactory`, +`ResponseContentType` and `NoContentForNotifications` are not used. diff --git a/AustinHarris.JsonRpc.Newtonsoft/README.md b/AustinHarris.JsonRpc.Newtonsoft/README.md index 595640a..07b305b 100644 --- a/AustinHarris.JsonRpc.Newtonsoft/README.md +++ b/AustinHarris.JsonRpc.Newtonsoft/README.md @@ -2,15 +2,18 @@ Json.NET (Newtonsoft.Json) serializer for [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) 2.0. -The core package (`AustinHarris.JsonRpc`) parses the JSON-RPC envelope itself and ships a small dependency-free -serializer for parameters and results. Install this package when you want Json.NET to do the value conversions: -its converters, contract resolvers, `[JsonProperty]` attributes, date/float handling, and its tolerance for -non-strict JSON. +The core package (`AustinHarris.JsonRpc`) parses the JSON-RPC envelope itself and ships a built-in serializer for +parameters and results that needs no JSON library. Install this package when you want Json.NET to do the value +conversions: its converters, contract resolvers, `[JsonProperty]` attributes, date/float handling, and its +tolerance for non-strict JSON. ``` dotnet add package AustinHarris.JsonRpc.Newtonsoft ``` +Targets `netstandard2.0`, `netstandard2.1`, `net8.0` and `net10.0`; depends on Newtonsoft.Json 13.0.4 and the +`AustinHarris.JsonRpc` core package. + ## Choosing the serializer Process-wide (every session that does not override it): @@ -27,8 +30,7 @@ Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings)); Per session: ```csharp -Handler.GetSessionHandler("session-42").Serializer = new NewtonsoftJsonRpcSerializer(settings); -// or: Config.SetSerializer("session-42", new NewtonsoftJsonRpcSerializer(settings)); +Config.SetSerializer("session-42", new NewtonsoftJsonRpcSerializer(settings)); ``` Per call (overrides both): @@ -38,8 +40,14 @@ var serializer = new NewtonsoftJsonRpcSerializer(settings); string response = JsonRpcProcessor.ProcessSync(sessionId, json, context, serializer); ``` -Create the serializer once and reuse it: it holds one `JsonSerializer` built from the settings, and the processor -caches an envelope reader per serializer instance. +When each level is the right one is covered in +[docs/serializers.md](https://github.com/Astn/JSON-RPC.NET/blob/master/docs/serializers.md). + +Create the serializer once and reuse it: it holds one `JsonSerializer` built from the settings, and the processor's +synchronous path keeps an envelope reader in per-thread scratch storage for as long as the serializer instance +stays the same, so a new serializer per call throws that reuse away. That `JsonSerializer`, its converters, +contract resolver and callbacks are used concurrently by unrelated requests: configure the instance before serving +traffic, do not mutate it while requests are in flight, and make custom components thread-safe. ## Settings-based helpers (1.x compatibility) @@ -59,15 +67,20 @@ Each distinct settings instance is turned into a serializer the first time it is * Every conversion honours the settings: params, results, `error.data`, and the `JsonRequest.Params` handed to pre/post-process handlers (a `JObject` / `JArray` / primitive, as `JsonConvert.DeserializeObject` returns). * Json.NET's defaults already match the library's wire conventions: compact output, `3.0` for whole floating - values, ISO-8601 dates (fraction only when non-zero, trailing zeros trimmed) with the offset, `char` as a one-character string, nulls - written, members in declaration order, case-insensitive member names on input, numbers coerced to - `bool`/`char`/floating types. + values, ISO-8601 dates (fraction only when non-zero, trailing zeros trimmed; `Z` for UTC, the offset for Local, + nothing for Unspecified), `char` as a one-character string, nulls written, members in declaration order, + case-insensitive member names on input, numbers coerced to `bool`/`char`/floating types. * Leniency. Json.NET accepts more than RFC 8259, and with this serializer selected so does the envelope reader: single-quoted strings, unquoted member names and trailing commas are accepted in the request, e.g. `{method:'add',params:[1,2],id:1}`. With the built-in serializer the same request is a `-32700` parse error. + Leniency applies to the HTTP endpoint and to in-process calls; on a raw Kestrel connection the framer that + splits the stream into documents accepts strict JSON only. * `JsonConvert.DefaultSettings`, if your process sets it, is the baseline exactly as it is for `JsonConvert`. -Conversion failures throw and are reported to the client as `-32603 Internal Error`. +A value Json.NET cannot convert to the parameter's type (a `JsonException`, or a format, overflow or cast +exception while reading an argument) is reported as `-32602 Invalid params`, with `data` naming the parameter and +the expected type; the value sent is never echoed. A type the serializer cannot handle at all, or an exception +inside your method, is `-32603 Internal error`. ## Performance notes diff --git a/AustinHarris.JsonRpc.SystemTextJson/README.md b/AustinHarris.JsonRpc.SystemTextJson/README.md index 027cbfa..b0aee69 100644 --- a/AustinHarris.JsonRpc.SystemTextJson/README.md +++ b/AustinHarris.JsonRpc.SystemTextJson/README.md @@ -12,8 +12,8 @@ response buffer. dotnet add package AustinHarris.JsonRpc.SystemTextJson ``` -Targets netstandard2.0, netstandard2.1, net8.0 and net10.0; depends on System.Text.Json 10.0.x and the -`AustinHarris.JsonRpc` core. +Targets `netstandard2.0`, `netstandard2.1`, `net8.0` and `net10.0`; depends on System.Text.Json 10.0.3 and the +`AustinHarris.JsonRpc` core package. ## Use @@ -31,11 +31,10 @@ Config.SetSerializer(new SystemTextJsonRpcSerializer(options)); Per session: ```csharp -Handler.GetSessionHandler(sessionId).Serializer = new SystemTextJsonRpcSerializer(options); -// equivalently: Config.SetSerializer(sessionId, serializer) +Config.SetSerializer(sessionId, new SystemTextJsonRpcSerializer(options)); ``` -Per call (overrides both the session and the global default): +Per call (overrides both the session and the process-wide default): ```csharp var serializer = new SystemTextJsonRpcSerializer(options); @@ -44,14 +43,20 @@ string response = JsonRpcProcessor.ProcessSync(sessionId, json, context, seriali JsonRpcProcessor.Process(sessionId, requestBytes, outputWriter, context, serializer); ``` -Create one instance and reuse it: the instance owns nothing mutable, and its options are read-only after -construction. +When each level is the right one is covered in +[docs/serializers.md](https://github.com/Astn/JSON-RPC.NET/blob/master/docs/serializers.md). + +Create one instance and reuse it. The options it actually uses (`EffectiveOptions`) are read-only; the object you +passed is never changed. ## Default options `new SystemTextJsonRpcSerializer()` uses `SystemTextJsonRpcSerializer.DefaultOptions`, a single immutable -`JsonSerializerOptions` that reproduces the wire conventions of the built-in (jsmn) and Json.NET serializers, so -the same request yields byte-identical responses whichever serializer is installed: +`JsonSerializerOptions` that reproduces the wire conventions of the built-in and Json.NET serializers for the +envelope, primitives, dates and plain objects. The serializers are still not interchangeable for every request: +System.Text.Json refuses some coercions the other two accept (a JSON number sent for a `string` parameter, for +example), the CLR types each supports differ, and so does the object model handed to handlers. Test client-visible +requests and responses before switching. | Setting | Value | | --- | --- | @@ -60,7 +65,7 @@ the same request yields byte-identical responses whichever serializer is install | `PropertyNamingPolicy` | `null` (member names as declared, in declaration order) | | `PropertyNameCaseInsensitive` | `true` | | `IncludeFields` | `true` (public fields bind like properties) | -| `Encoder` | `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` (no `+` for `+`, etc.) | +| `Encoder` | `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` (`+`, `<`, `>`, `&` and non-ASCII text are written as they are, not Unicode-escaped) | | `NumberHandling` | `AllowReadingFromString` | | `Converters` | the `JsonRpcConverters` below | @@ -70,7 +75,7 @@ Registered by `JsonRpcConverters.AddMissing(options)`; each is public so it can | Converter | Writes | Reads | | --- | --- | --- | -| `JsonRpcNumberConverterFactory` (double, float, decimal, and the 8 integer types) | whole float/double/decimal values with `.0` (`3.0`, `71.0`, `0.0`), otherwise shortest round-trip (`1.2345`, `3.14159`); NaN/Infinity as bare symbols like Json.NET | numbers, numeric strings, `true`/`false` as 1/0; fractional input for integer types is rounded to even | +| `JsonRpcNumberConverterFactory` (double, float, decimal, and the 8 integer types) | whole float/double/decimal values with `.0` (`3.0`, `71.0`, `0.0`), otherwise shortest round-trip (`1.2345`, `3.14159`); NaN and the infinities as the quoted strings `"NaN"`, `"Infinity"`, `"-Infinity"`, as Json.NET and the built-in serializer write them | numbers, numeric strings, `true`/`false` as 1/0; fractional input for integer types is rounded to even | | `JsonRpcBooleanConverter` | `true`/`false` | booleans, numbers (non-zero is true), `"true"`/`"false"`/numeric strings | | `JsonRpcCharConverter` | a one-character string | a one-character string or a number (`98` reads as `'b'`) | | `JsonRpcDateTimeConverter` | `yyyy-MM-ddTHH:mm:ss[.fffffff]K`, the fraction only when non-zero and with trailing zeros trimmed, exactly as Json.NET and the built-in serializer write it | any ISO-8601 text via `DateTime.Parse(..., InvariantCulture, RoundtripKind)`: an offset in the input yields a Local `DateTime` | @@ -88,8 +93,9 @@ converters, `TypeInfoResolver`, ...). The only adjustment is the converter set: - If `options.Converters` already contains every `JsonRpcConverters` type, the instance is used as-is (it is made read-only, as System.Text.Json would do on first use anyway). - Otherwise the options are copied with `new JsonSerializerOptions(options)` and the missing converters are - appended to the copy. The object you passed is never mutated, so it is safe to share with other code. The - converters are appended *after* yours, so a converter you registered for the same type keeps precedence. + appended to the copy, which is then made read-only. The object you passed is never mutated, so it is safe to + share with other code. The converters are appended *after* yours, so a converter you registered for the same + type keeps precedence. `Options` returns what you passed (null for the defaults); `EffectiveOptions` returns the instance actually used. To start from the library defaults and tweak them: @@ -107,5 +113,9 @@ If you build options from scratch, remember that the wire conventions above (`In ## Object model Pre/post-process handlers receive `JsonRequest.Params` as a `JsonElement` (the result of deserializing the -params to `object`), and `Handler.Handle(JsonRequest)` accepts a `JsonElement` back. Conversion failures throw -`JsonException`; the core reports them as JSON-RPC error -32603. +params to `object`), and `Handler.Handle(JsonRequest)` accepts a `JsonElement` back. + +A client value that cannot be converted to the parameter's type throws `JsonException`, which the core reports +as `-32602 Invalid params` with `data` naming the parameter and the expected type; the value sent is never echoed. +System.Text.Json is stricter than the other two serializers here: a JSON number sent for a `string` parameter is +refused. An unsupported CLR type or an internal serialization failure remains `-32603 Internal error`. diff --git a/README.md b/README.md index f0639b2..16cd67f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,42 @@ +# JSON-RPC.Net -![Screenshot](http://i.imgur.com/rxHaXLb.png) - -json-rpc.net -============ ![Build Master](https://github.com/Astn/JSON-RPC.NET/workflows/Build%20Master/badge.svg) ![NuGet Badge](https://buildstats.info/nuget/AustinHarris.JsonRpc) -JSON-RPC.Net is a high performance [JSON-RPC 2.0](https://www.jsonrpc.org/specification) server for .NET. It turns a JSON-RPC request into a JSON-RPC response and stays out of the way of your transport: bytes in, bytes out. Host it in Kestrel, a console app, sockets, pipes, or inside the browser as WebAssembly. +JSON-RPC.Net is a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) server for .NET. You give it a request document and it gives you the response document, bytes in and bytes out; the transport is yours. Host it in Kestrel, a console app, sockets, pipes, or a Blazor WebAssembly page. + +Version 2.0 rebuilt the pipeline around UTF-8 bytes and made the JSON serializer pluggable. The core depends on no JSON library: Json.NET and System.Text.Json ship as separate packages, and the built-in serializer needs neither. On one core it answers a small request in about 217 ns, with no allocation for numeric parameters. A Kestrel host on an 8-core desktop answers over 14 million requests per second over pipelined TCP. [Benchmarks](#benchmarks) gives the method and the full tables. -Version 2.0 rebuilt the pipeline around UTF-8 bytes and made the JSON serializer pluggable. The core has no JSON library dependency; Json.NET and System.Text.Json ship as separate packages, and a built-in serializer needs neither. On one core it answers a small request in about 250 ns with no allocation; a Kestrel host on an 8-core desktop answers over 14 million requests per second over pipelined TCP. +It is a server only. There are no client proxies and no server-to-client calls. If you need a bidirectional RPC framework, look at [StreamJsonRpc](https://www.nuget.org/packages/StreamJsonRpc); the benchmarks compare the two. -Documentation site: [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RPC.NET/). +This README and the package guides are also published at [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RPC.NET/). What the library does by default and what it leaves to you is under [Security](#security). - [Packages](#packages) - [Requirements](#requirements) - [Installation](#installation) - [Getting started](#getting-started) -- [Hosting modes](#hosting-modes) +- [Defining methods](#defining-methods) +- [Hosting](#hosting) +- [Errors](#errors) +- [Asynchronous methods and cancellation](#asynchronous-methods-and-cancellation) +- [Sessions and context](#sessions-and-context) - [Configuration](#configuration) +- [Security](#security) - [Benchmarks](#benchmarks) - [Upgrading from 1.x](#upgrading-from-1x) +- [Versioning and support](#versioning-and-support) - [Building](#building) +- [License](#license) ## Packages +All four packages are MIT licensed and ship together with the same version number. + | Package | What it is | | --- | --- | -| `AustinHarris.JsonRpc` | The server. Envelope parsing, method dispatch, parameter binding, error mapping, sessions. Ships with a dependency-free serializer (a span port of [jsmn](https://github.com/zserge/jsmn) plus a cached reflection mapper). | +| `AustinHarris.JsonRpc` | The server. Envelope parsing, method dispatch, parameter binding, error mapping, sessions. Ships with the built-in serializer, `JsmnSerializer`: a span port of the [jsmn](https://github.com/zserge/jsmn) tokenizer plus a cached reflection mapper, with no JSON library behind it. | | `AustinHarris.JsonRpc.Newtonsoft` | Json.NET 13 serializer. The compatibility choice: `JsonSerializerSettings`, `[JsonProperty]`, converters, lenient input. | | `AustinHarris.JsonRpc.SystemTextJson` | System.Text.Json serializer. `JsonSerializerOptions`, `Utf8JsonReader`/`Utf8JsonWriter` straight on the request bytes. | -| `AustinHarris.JsonRpc.AspNetCore` | Kestrel hosting: an HTTP endpoint on `PipeReader`/`BodyWriter`, a `ConnectionHandler` for raw TCP/Unix-socket/named-pipe connections, and DI registration of services. | +| `AustinHarris.JsonRpc.AspNetCore` | Kestrel hosting: an HTTP endpoint on `PipeReader`/`BodyWriter`, a `ConnectionHandler` for raw TCP, Unix socket and named pipe connections, and DI registration of services. | ## Requirements @@ -43,7 +51,11 @@ Documentation site: [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RP `AustinHarris.JsonRpc.AspNetCore` targets `net8.0` and `net10.0`. -Core dependencies: `NonBlocking` 2.1.2 (lock-free dictionary for the session registry) and, on `netstandard` only, `System.Memory`; `netstandard2.0` also references `System.Threading.Tasks.Extensions` for `ValueTask`. The core uses no reflection emit, so it runs under the WebAssembly interpreter, AOT and trimmed builds. +The "Covers" column is what each target framework admits, not what is tested. CI runs the test suite on `net8.0` and `net10.0`; the WebAssembly sample is built in CI and run by hand. The other runtimes can load the `netstandard` assets but are not part of the test matrix. On .NET Framework, 4.7.2 or later avoids the binding redirects that 4.6.1 to 4.7.1 need for `netstandard2.0` libraries. + +Dependencies at 2.0.0: `NonBlocking` 2.1.2 (the lock-free dictionary behind the session registry) and, on `netstandard` only, `System.Memory` 4.6.3; `netstandard2.0` also references `System.Threading.Tasks.Extensions` 4.5.0 for `ValueTask`. The Newtonsoft package depends on Newtonsoft.Json 13.0.4 and the System.Text.Json package on System.Text.Json 10.0.3. + +The core uses no reflection emit, so it runs under the WebAssembly interpreter and under WebAssembly AOT (see [samples/WasmHost](samples/WasmHost)). It is not annotated for trimming: services and their `[JsonRpcMethod]` members are found by reflection, so keep those types rooted if you publish trimmed. ## Installation @@ -53,13 +65,11 @@ dotnet add package AustinHarris.JsonRpc Add `AustinHarris.JsonRpc.Newtonsoft` or `AustinHarris.JsonRpc.SystemTextJson` if you want that serializer, and `AustinHarris.JsonRpc.AspNetCore` to host in Kestrel. -To host inside classic ASP.NET (System.Web) there is also `AustinHarris.JsonRpc.AspNet`, which targets .NET Framework 4.0 only and is built from its own legacy project. - ## Getting started ### 1. Declare a service -Derive from `JsonRpcService` and mark the methods you want to expose with `[JsonRpcMethod]`. Constructing the service registers it with the default session, so you only need to keep the instance alive. +Derive from `JsonRpcService` and mark the methods you want to expose with `[JsonRpcMethod]`. Constructing the service registers it, so you only need to keep the instance alive. ```csharp using AustinHarris.JsonRpc; @@ -77,7 +87,48 @@ public class CalculatorService : JsonRpcService } ``` -Methods can be `private`; parameters may be positional (`"params":[1,2]`) or named (`"params":{"l":1,"r":2}`). Optional parameters with default values are honoured, and a parameter's JSON name can be overridden with `[JsonRpcParam("name")]`. Any class works, not only `JsonRpcService` subclasses: bind an instance with `ServiceBinder.BindService(sessionId, instance)`. +Methods can be `private`; parameters may be positional (`"params":[1,2]`) or named (`"params":{"l":1,"r":2}`). Optional parameters with default values are honoured, and a parameter's JSON name can be overridden with `[JsonRpcParam("name")]`. + +Every method lives in a *session*, a named set of methods. Everything above goes into the default session (`Handler.DefaultSessionId()`), which is all most applications need. Overloads that take a `sessionId` let one process serve separate method sets; see [Sessions and context](#sessions-and-context). + +### 2. Process requests + +```csharp +using System; +using System.Buffers; +using System.Text; +using AustinHarris.JsonRpc; + +var service = new CalculatorService(); // binds itself to the default session; keep a reference + +// Strings, asynchronous invocation. +string response = await JsonRpcProcessor.ProcessAsync("{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":1}"); +// {"jsonrpc":"2.0","result":3.0,"id":1} + +// Strings, synchronous, on the calling thread. Named parameters. +string sync = JsonRpcProcessor.ProcessSync("{\"method\":\"multiply\",\"params\":{\"l\":6,\"r\":7},\"id\":2}"); +// {"jsonrpc":"2.0","result":42,"id":2} + +// Bytes: the native path. The string overloads transcode into it. +byte[] request = Encoding.UTF8.GetBytes("{\"method\":\"add\",\"params\":[2,3],\"id\":3}"); +var output = new ArrayBufferWriter(); +JsonRpcProcessor.Process(Handler.DefaultSessionId(), request.AsSpan(), output); +Console.WriteLine(Encoding.UTF8.GetString(output.WrittenSpan)); // nothing is written for a notification +``` + +Batches (`[{...},{...}]`) and notifications (requests without an `id`) are handled per the spec: a batch answers with an array, a notification produces nothing. The byte overloads take `ReadOnlySpan`, `ReadOnlyMemory` or `ReadOnlySequence`; pass a `byte[]` as `AsSpan()`, because on C# 12 a bare array is ambiguous between the memory and span overloads. + +That is the whole server. The rest of this page is about exposing methods, putting a transport in front, and what happens when things go wrong. + +## Defining methods + +### Classes + +Any class works, not only `JsonRpcService` subclasses: bind an instance with `ServiceBinder.BindService(sessionId, instance)`. A `JsonRpcService` subclass binds itself to the default session in its constructor, even when a host later binds the same instance to another session as well. + +One instance serves every request on every thread, so a service must be thread-safe. When the AspNetCore package builds a service through DI it is a singleton created once at startup; see [Kestrel HTTP endpoint](#kestrel-http-endpoint). + +### Delegates A method does not need a class at all. Any delegate becomes a method with `ServiceBinder.BindMethod`; a lambda keeps its parameter names for named params: @@ -89,7 +140,9 @@ ServiceBinder.BindMethod(sessionId, "scale", (double v, double f) => v * f, ServiceBinder.UnbindMethod("add"); ``` -A name already registered on the session is an error (unbind it first); a delegate whose parameter names cannot be recovered (a closed delegate created with `Delegate.CreateDelegate`) gets `arg1`, `arg2`, ... unless you pass names. Task and ValueTask delegates are supported through `ProcessAsync`, see [Asynchronous methods](#asynchronous-methods). +A name already registered on the session is an error (unbind it first); a delegate whose parameter names cannot be recovered (a closed delegate created with `Delegate.CreateDelegate`) gets `arg1`, `arg2`, ... unless you pass names. `Task` and `ValueTask` delegates are served by `ProcessAsync`; see [Asynchronous methods and cancellation](#asynchronous-methods-and-cancellation). + +### Interfaces An interface can define the exposed contract, including a tree of interface-typed properties. Implementation-only methods stay private to the host; parameter names, `[JsonRpcParam]`, aliases and optional defaults come from the interface: @@ -107,39 +160,36 @@ using var characterOnly = ServiceBinder.BindInterface(sessionId, world, // Include can also inspect m.Method for the host's own interface attributes. ``` -Recursion defaults to on. Readable, non-indexed interface properties are evaluated **once per mount at registration**, so getters may have side effects; changing a property afterwards does not replace the captured child. The tree is flattened and compiled at registration, adding no tree traversal or binding cost per request. `Recursive = false` binds only root methods. `Prefix`, `Separator` and invariant `Casing = RpcNameCasing.CamelCase` control generated names; explicit `[JsonRpcMethod("alias")]` leaves remain literal. `NameRule` can return the complete wire name instead. Inherited and explicit implementations and closed generic interfaces work; generic methods and default interface bodies are rejected. Task and ValueTask members are asynchronous registrations served by `ProcessAsync`; `[JsonRpcMethod(ContextFlow = RpcContextFlow.Flow)]` on the interface member opts into context flow across awaits (see [Asynchronous methods](#asynchronous-methods)). - -Registration publishes the whole tree at once or throws without exposing any of it. Empty names, reserved `rpc.` names, duplicates and names already in the session are errors, as are null children, throwing getters, cycles and paths deeper than 32 properties. Getter side effects cannot be rolled back. Keep the handle for unbinding: `Dispose` is idempotent and does not dispose your objects; calls already resolved can finish on their captured implementation. - -### 2. Process requests +Each interface-typed property becomes a name segment, so `IWorld.Character.MoveAndRotate` is exposed as `Character.MoveAndRotate`. The whole tree is walked and compiled when you call `BindInterface`, so a request pays nothing for it. -```csharp -using AustinHarris.JsonRpc; +What happens at registration: -var service = new CalculatorService(); +- Each property getter runs once, and the object it returns is the one that serves calls. Setting the property later changes nothing. A getter may have side effects; they happen once and are not undone if registration fails. +- Registration is all or nothing. It throws, exposing no method, on an empty or duplicate name, a name starting with `rpc.`, a name already bound in the session, a null or throwing getter, a cycle, or a path deeper than 32 properties. +- Inherited members, explicit implementations and closed generic interfaces are supported. Generic methods and default interface method bodies are rejected. -// Strings, asynchronous invocation. -string response = await JsonRpcProcessor.ProcessAsync("{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":1}"); -// {"jsonrpc":"2.0","result":3.0,"id":1} +Naming, through `RpcInterfaceBindingOptions`: -// Strings, synchronous, on the calling thread. -string sync = JsonRpcProcessor.ProcessSync("{\"method\":\"multiply\",\"params\":{\"l\":6,\"r\":7},\"id\":2}"); -// {"jsonrpc":"2.0","result":42,"id":2} +| Option | Default | Effect | +| --- | --- | --- | +| `Recursive` | `true` | `false` binds only the root interface's methods | +| `Prefix` | `""` | prepended to every generated name | +| `Separator` | `"."` | joins property segments and the method name | +| `Casing` | `Preserve` | `CamelCase` lower-cases the first letter of each generated segment (invariant culture) | +| `Include` | all | a predicate over `RpcInterfaceMethod` (`Path`, `Method`, `Interface`, `Leaf`, `DefaultName`) | +| `NameRule` | none | returns the complete wire name, replacing the rules above | -// Bytes. This is the native path; the string overloads transcode into it. -var output = new ArrayBufferWriter(); -JsonRpcProcessor.Process(Handler.DefaultSessionId(), requestBytes /* ReadOnlySpan, ReadOnlyMemory or ReadOnlySequence */, output); -``` +An explicit `[JsonRpcMethod("alias")]` on an interface method is used as written. `Task` and `ValueTask` members are served by `ProcessAsync`; `[JsonRpcMethod(ContextFlow = RpcContextFlow.Flow)]` on the interface member opts into context flow across awaits (see [Asynchronous methods and cancellation](#asynchronous-methods-and-cancellation)). -Batches (`[{...},{...}]`) and notifications (requests without an `id`) are handled per the spec: a batch answers with an array, a notification produces nothing. +`BindInterface` returns an `RpcBinding`. Disposing it unbinds exactly this tree (not a later registration under the same names), is safe to call twice, and does not dispose your objects. Calls already dispatched finish on the implementation they started with. -## Hosting modes +## Hosting The core is transport-agnostic. Pick whichever of these fits, or build your own on the byte entry point. ### In-process (strings or bytes) -The calls above. The byte overloads take what a `PipeReader` gives you (`ReadOnlySequence`) and write to what a `PipeWriter`, a socket buffer or `HttpResponse.BodyWriter` is (`IBufferWriter`). Nothing is written for a notification, so check `output.WrittenCount` before sending. For transports that carry several documents per connection, `JsonFramer.TryReadDocument` slices complete documents out of a byte stream without parsing them. +Call the processor yourself, as in [Getting started](#getting-started). The byte overloads take what a `PipeReader` gives you (`ReadOnlySequence`) and write to any `IBufferWriter`: a `PipeWriter`, a socket buffer or `HttpResponse.BodyWriter`. Nothing is written for a notification, so check `output.WrittenCount` before sending. If your transport carries several documents per connection, `JsonFramer.TryReadDocument` cuts complete documents out of the byte stream without parsing them. ### Kestrel HTTP endpoint @@ -148,87 +198,162 @@ using AustinHarris.JsonRpc.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddJsonRpc(); -builder.Services.AddJsonRpcService(); // built by DI; any class with [JsonRpcMethod] works, controllers included +builder.Services.AddJsonRpcService(); // built by DI; any class with [JsonRpcMethod] works var app = builder.Build(); app.MapJsonRpc("/rpc"); // POST /rpc; compose with RequireAuthorization() etc. app.Run(); ``` -The endpoint reads the body from `PipeReader` and writes the response into `BodyWriter`; nothing becomes a string on the way through. A request or batch answers `200 application/json`, a notification `204`. Inside a method `JsonRpcContext.Current().Value` is the `HttpContext`. Enable async service methods with `builder.Services.AddJsonRpc(o => o.EnableAsyncMethods = true)` (default false). HTTP processing passes `RequestAborted` to `ProcessAsync` and keeps the body reader leased until completion. Options (session selection per request, serializer, body size limit, content type) are on `AddJsonRpc(o => ...)`; see the [package README](AustinHarris.JsonRpc.AspNetCore/README.md). +A request or batch answers `200 application/json`; a notification answers `204`. The body goes from `PipeReader` to `BodyWriter` without becoming a string. Inside a method, `JsonRpcContext.Current().Value` is the `HttpContext`. + +`AddJsonRpcService()` registers `T` as a singleton unless `T` is already registered. It is resolved once from the root container when the host starts, and that one instance serves every request on every thread, so it must be thread-safe and cannot take scoped dependencies such as an EF Core `DbContext`. For per-request services, resolve them in the method from `((HttpContext)Handler.RpcContext()).RequestServices`. To await `Task` and `ValueTask` methods, set `o.EnableAsyncMethods = true` in `AddJsonRpc`; the request is then cancelled when the client disconnects (`RequestAborted`). The other options (session per request, serializer, body size limit, content type) and the per-endpoint overload `MapJsonRpc(pattern, options)` are in the [package README](AustinHarris.JsonRpc.AspNetCore/README.md). ### Kestrel raw connections (TCP, Unix socket, named pipe) ```csharp builder.WebHost.ConfigureKestrel(k => { - k.ListenAnyIP(9000, l => l.UseConnectionHandler()); + k.ListenLocalhost(9000, l => l.UseConnectionHandler()); // k.ListenUnixSocket("/tmp/rpc.sock", l => l.UseConnectionHandler()); // k.ListenNamedPipe("rpc", l => l.UseConnectionHandler()); }); ``` -Clients write JSON documents back to back on the connection (whitespace or a newline between them is fine) and read responses in order; notifications produce nothing. The framer that splits the stream into documents only understands strict JSON: over a raw connection, single-quoted strings and other lenient syntax are not supported even with the Json.NET serializer. With `EnableAsyncMethods = true`, each framed document finishes before the next begins, and completed replies are flushed before waiting for a suspended document. The read buffer stays leased throughout invocation. See [Benchmarks](#benchmarks). +Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses in the same order, also back to back with no separator; notifications produce nothing. The framer accepts strict JSON only, so single-quoted strings and other lenient syntax are refused on a raw connection even with the Json.NET serializer. + +A raw connection has no authentication, authorisation or rate limiting; those are HTTP middleware and do not run here. Listen on loopback or a Unix socket, or put something in front that authenticates. A document larger than `MaxRequestBytes` (4 MB) aborts the connection. + +With `EnableAsyncMethods = true`, documents on one connection are processed one at a time in order, and replies already finished are flushed before the connection waits on a slow method. Separate connections run concurrently. ### Blazor WebAssembly -The core runs inside the browser. [samples/WasmHost](samples/WasmHost) is a Blazor WebAssembly app where JavaScript hands a request document to a `[JSExport]`/`[JSInvokable]` method that calls `JsonRpcProcessor.ProcessSync` and returns the response, with no HTTP involved. The same service class then serves both the browser and the server. The sample page also benchmarks JSON-RPC against plain Blazor interop; the numbers are in its README. +The core runs inside the browser. [samples/WasmHost](samples/WasmHost) is a Blazor WebAssembly app where JavaScript hands a request document to a `[JSExport]`/`[JSInvokable]` method that calls the processor and returns the response, with no HTTP involved. The same service class then serves both the browser and the server. The sample page also benchmarks JSON-RPC against plain Blazor interop; the numbers are in its README. -### Classic ASP.NET +### Classic ASP.NET (System.Web) -`AustinHarris.JsonRpc.AspNet` hosts the 1.x-style `JsonRpcHandler` in System.Web on .NET Framework 4.0. It is built from its own project and unchanged in this release. +`AustinHarris.JsonRpc.AspNet` is a 1.x package and is not part of 2.0. It targets .NET Framework 4.0, and the 2.0 core needs `netstandard2.0` (.NET Framework 4.6.1 or later), so the two cannot be combined. To host in System.Web on 2.0, call `JsonRpcProcessor.ProcessSync` from your own `IHttpHandler`. -## Configuration +## Errors -Everything is on `Config` (process-wide) with per-session overrides. Resolution is per call, then per session, then global. +### Exception disclosure -### Serializer +By default (`Config.IncludeExceptionDetails = false`), an unhandled exception thrown by a method becomes `-32603 Internal error` and `error.data` carries the exception's fully qualified CLR type name and its `Message`. Source, stack trace, HResult and inner exceptions are omitted. This is limited disclosure, not complete redaction: exception messages must not contain secrets. + +Set `Config.IncludeExceptionDetails = true` only for trusted development clients; it adds `Source`, `StackTraceString`, `HResult` and the `InnerException` chain. To suppress the type and message as well, replace internal errors in an error handler. The handler sees the original `Exception` in `data` for this case, so filter on that rather than on the code: ```csharp -// Global -Config.SetSerializer(new SystemTextJsonRpcSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); +Config.SetErrorHandler((request, error) => + error.data is Exception ? new JsonRpcException(-32603, "Internal Error", null) : error); +``` -// Per session -Config.SetSerializer("legacy-clients", new NewtonsoftJsonRpcSerializer(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore })); +A `JsonRpcException` thrown by the application keeps the `data` it was given. -// Per call -string json = JsonRpcProcessor.ProcessSync(sessionId, request, context, serializer); +### Handlers + +Throw `JsonRpcException(code, message, data)` to return an error of your own. To reshape errors, or to inspect requests on the way in and out, register handlers. Each handler belongs to one session: + +```csharp +// Default session +Config.SetErrorHandler((request, exception) => new JsonRpcException(-32000, "Server error", exception.data)); +Config.SetParseErrorHandler((rawJson, exception) => exception); +Config.SetPreProcessHandler((request, context) => null); // return a JsonRpcException to reject; may replace Method/Params/Id +Config.SetPostProcessHandler((request, response, context) => null); // return a JsonRpcException to replace the result + +// Another session: other sessions do not inherit the default session's handlers +Config.SetErrorHandler("client-42", (request, exception) => exception); +Config.SetParseErrorHandler("client-42", (rawJson, exception) => exception); +Config.SetBeforeProcessHandler("client-42", (request, context) => null); +Handler.GetSessionHandler("client-42").SetPostProcessHandler((request, response, context) => null); ``` -The built-in serializer is the default and reproduces Json.NET's wire conventions (member order, `.0` on whole floats, ISO dates, nulls written), so switching is invisible to clients. Library options go into the serializer's constructor and nowhere else: +The overloads without a session id set the **default session's** handler, not a process-wide one. A pre- or post-process handler moves its session onto a slower path, which builds `JsonRequest` and `JsonResponse` objects for the handler to see. Leave them unset unless you need them. -| Serializer | Constructor | Notes | +### Error codes + +The errors the library raises itself carry structured `data`, identical for every serializer, and the error handler receives the same object: + +| Code | `error.data` | Object seen by the error handler | | --- | --- | --- | -| built-in | `new JsmnSerializer(lenient: false, maxDepth: 64)` | `lenient` accepts `'single quotes'`, unquoted keys and trailing commas | -| Json.NET | `new NewtonsoftJsonRpcSerializer(settings)` | one `JsonSerializer` is built from the settings and reused; input is always lenient | -| System.Text.Json | `new SystemTextJsonRpcSerializer(options)` | the package adds its wire-format converters to a copy of your options when they are missing | +| `-32601` Method not found | `{"method":""}` | `MethodNotFoundInfo` | +| `-32602` Invalid params: count, missing, unknown or repeated named parameter | a sentence, e.g. `"Named parameter 'b' was not present."` | `string` | +| `-32602` Invalid params: a value the serializer could not convert | `{"reason":"conversion","parameter":"b","index":1,"expectedType":"int32"}` plus `"message"` when `Config.IncludeExceptionDetails` is on; the value sent is never echoed | `ParameterErrorInfo` (with the serializer's exception in `Cause`) | +| `-32603` Internal error: the method threw, or a parameter's type is one the serializer cannot handle | `{ClassName, Message, ...}`, see [Exception disclosure](#exception-disclosure) | `Exception` | -The full contract, what the core fixes versus what a serializer decides, is in [docs/serializers.md](docs/serializers.md). +In the second `-32602` row, "could not convert" means the serializer refused the value (`JsonRpcBindException`, `FormatException`, `OverflowException`, `InvalidCastException`, or any `JsonException` from System.Text.Json or Json.NET); what each serializer accepts (say `"7"` for an `int`) is its own decision, see [docs/serializers.md](docs/serializers.md). -### Nesting depth +## Asynchronous methods and cancellation -Every serializer exposes `MaxDepth` (default 64). A request nested deeper is answered `-32700` before any hook or binding runs, so recursive parameter conversion is bounded by the same number the JSON library itself enforces: the built-in serializer's constructor argument, `JsonSerializerOptions.MaxDepth`, or `JsonSerializerSettings.MaxDepth`. +A method may return `Task`, `Task`, `ValueTask` or `ValueTask`. Call it through `JsonRpcProcessor.ProcessAsync`. The processor awaits the method and writes its result; `Task` and `ValueTask` answer `null`. A method that completes synchronously runs inline, allocates nothing on the library's side, and the byte overloads then return `Task.CompletedTask`. -### The `jsonrpc` member +```csharp +[JsonRpcMethod("lookup")] +public async Task Lookup(int id, [JsonRpcCancellation] CancellationToken cancellationToken) + => await repository.FindAsync(id, cancellationToken).ConfigureAwait(false); + +await JsonRpcProcessor.ProcessAsync(sessionId, requestMemory, output, + context: requestContext, cancellationToken: cancellationToken); +string response = await JsonRpcProcessor.ProcessAsync(sessionId, requestJson, + context: requestContext, cancellationToken: cancellationToken); +``` + +**Which entry point to use.** `ProcessAsync` is the only entry point that awaits. `Process` and `ProcessSync` answer an async method with `-32603` and a message pointing to `ProcessAsync`, and do not call it. The older `Task Process(…)` overloads run the synchronous path on the thread pool through `Task.Factory.StartNew`; despite returning a `Task`, they do not await async methods either. + +**Order.** A batch runs one request at a time, in order. Notifications are awaited like any other request. + +**Cancellation.** To receive the processor's token, a method declares a `CancellationToken` parameter marked `[JsonRpcCancellation]`. That parameter never binds from JSON and is left out of the SMD. A `CancellationToken` parameter without the attribute is rejected at registration. A synchronous method called through `ProcessAsync` receives the token too; through `Process` and `ProcessSync` it receives the default token. When the token fires: + +- the processor checks it before each invocation, between batch elements, and before writing the response; +- a method already running is waited for, not abandoned, so a method that ignores the token delays cancellation; +- nothing is written to `output`, the returned task is cancelled, and whatever the method already did stays done. + +An `OperationCanceledException` that a method throws while the processor's token has not fired is an ordinary error. + +**Buffers.** The byte overloads accept `ReadOnlyMemory`, `ReadOnlySequence` or `ReadOnlySpan`. The span overload and segmented sequences are copied before the first await. For memory you pass in, keep the request bytes unchanged and the output writer to yourself until the task completes. No output span is held across an await. The task finishes when the response is written, not when your transport has flushed it. + +**Ambient context after an await.** `Handler.RpcContext()`, `Handler.RpcRequestId()`, `JsonRpcContext.Current()` and `Handler.RpcSetException()` work in an async method only up to its first real await. That default, `RpcContextFlow.None`, allocates nothing. Read what you need at the top of the method: ```csharp -Config.VersionPolicy = JsonRpcVersionPolicy.Lenient; // process default -Config.SetVersionPolicy("strict-clients", JsonRpcVersionPolicy.Strict); // per session; null follows the global +[JsonRpcMethod("lookup")] +public async Task Lookup(int id) +{ + var http = (HttpContext)Handler.RpcContext(); // before the first await + JsonRpcRequestId requestId = Handler.RpcRequestId(); // an owned copy, safe to keep + var item = await repository.FindAsync(id); + if (item == null) throw new JsonRpcException(-32000, "Not found", null); + return item; +} ``` -| Policy | Missing member | `"2.0"` | Anything else | -| --- | --- | --- | --- | -| `Lenient` (default) | accepted | accepted | `-32600 Invalid Request` | -| `Ignore` | accepted | accepted | accepted | -| `Strict` | `-32600 Invalid Request` | accepted | `-32600 Invalid Request` | +If a method needs the accessors after awaiting, opt it into `RpcContextFlow.Flow`. The accessors then work across sequential awaits and nested dispatch, and every invocation pays one allocation for the execution-context bridge, completed tasks included: -The default keeps tool harnesses that omit the member working while a client speaking another version is told so. `Ignore` is for talking to anything at all. +```csharp +[JsonRpcMethod(ContextFlow = RpcContextFlow.Flow)] +public async Task Lookup(int id) +{ + var item = await repository.FindAsync(id); + if (item == null) Handler.RpcSetException(new JsonRpcException(-32000, "Not found", null)); + return item; +} -### Exception details +ServiceBinder.BindMethod(sessionId, "lookup", new Func>(LookupAsync), + contextFlow: RpcContextFlow.Flow); +``` -An ordinary exception thrown by a method reaches the client as `-32603` with `error.data = {ClassName, Message}`. Set `Config.IncludeExceptionDetails = true` to also send `Source`, `StackTraceString`, `HResult` and the `InnerException` chain. A `JsonRpcException` thrown by the application always keeps the `data` it was given. +In either mode, give parallel branches and background work their own copies of the context and request id, because the ambient state is cleared when the call finishes. Use `Handler.RpcRequestIdRaw()` only in the statement that reads it. -### Sessions and context +**Rejected at registration:** `async void`, custom awaitables, a `Task` that returns a `Task`, `IAsyncEnumerable`, and `ref`/`out` parameters on async methods (including the legacy trailing `ref JsonRpcException`). A method that returns a null `Task` is answered with `-32603`. + +Work that should outlive the request is better served by a job ticket: + +```csharp +[JsonRpcMethod] private string startExport(string filter) { var job = Jobs.Start(() => ExportAsync(filter)); return job.Id; } +[JsonRpcMethod] private ExportStatus exportStatus(string jobId) => Jobs.Status(jobId); +``` + +The client gets a ticket immediately and polls, or the transport pushes a notification when the job finishes. + +## Sessions and context Sessions let you host independent sets of services (for example one per connected client or tenant): @@ -238,7 +363,9 @@ string response = await JsonRpcProcessor.Process("client-42", request, context); Handler.DestroySession("client-42"); ``` -Pass an arbitrary context object through to your methods and read it with `Handler.RpcContext()` or `JsonRpcContext.Current().Value` (the Kestrel package passes the `HttpContext` or `ConnectionContext`): +Sessions are stored in a process-wide registry. Looking up an unknown session id creates a session that remains until `Handler.DestroySession(sessionId)` is called, and each new id makes every thread refresh its copy of the registry on its next lookup. So a session selector must map to a fixed set of ids: validate and bound ids obtained from routes, headers or other untrusted input, never feed arbitrary client values into one, and destroy tenant- or connection-scoped sessions when their lifetime ends. + +Pass an arbitrary context object through to your methods and read it with `Handler.RpcContext()` or `JsonRpcContext.Current().Value` (the AspNetCore package passes the `HttpContext` or `ConnectionContext`): ```csharp await JsonRpcProcessor.Process(request, context: httpContext); @@ -247,6 +374,8 @@ await JsonRpcProcessor.Process(request, context: httpContext); private string WhoAmI() => ((HttpContext)Handler.RpcContext()).User.Identity.Name; ``` +### The request id + The request's `id` is available the same way, read on demand from the request bytes, so a method that never asks pays nothing: ```csharp @@ -263,86 +392,102 @@ private string Track() } ``` -The snapshot is a small struct: an integer id allocates nothing, a string id allocates its decoded string, and the raw span never allocates. Synchronous dispatch keeps context per invocation and per thread; async Flow registrations carry it across sequential awaits (see [Asynchronous methods](#asynchronous-methods)). Take snapshots before parallel work. Nested dispatch sees its own id and restores the parent. A pre-process handler that replaces `JsonRequest.Id` changes what the method sees. A parameter named `id` is an ordinary parameter and binds from `params` only. +`JsonRpcRequestId` is a small struct you own: keep it anywhere. An integer id allocates nothing, a string id allocates its decoded string, and `RpcRequestIdRaw()` never allocates but is only valid until the method returns. If a method dispatches another request through `JsonRpcProcessor`, the inner method sees the inner id, and the outer id comes back afterwards. A pre-process handler that replaces `JsonRequest.Id` changes the id the method sees. A method parameter called `id` is unrelated: it binds from `params` like any other. + +After an `await`, these accessors follow the rules in [Asynchronous methods and cancellation](#asynchronous-methods-and-cancellation). + +## Configuration + +Settings live on `Config`. They do not all reach every scope: + +| Setting | Per call | Per session | Process-wide | +| --- | --- | --- | --- | +| Serializer | `serializer` argument | `Config.SetSerializer(sessionId, …)` | `Config.SetSerializer(…)` / `Config.Serializer` | +| `jsonrpc` version policy | | `Config.SetVersionPolicy(sessionId, …)` | `Config.VersionPolicy` | +| Exception details | | | `Config.IncludeExceptionDetails` | +| Error, parse-error, pre- and post-process handlers | | yes (see [Handlers](#handlers)) | no: the overloads without a session id set the **default session's** handler | -### Errors and hooks +Where more than one scope applies, the narrowest one wins. Context and cancellation are supplied per call. -Return a spec-compliant error by throwing `JsonRpcException(code, message, data)`, or shape errors globally or per session: +### Serializer ```csharp -Config.SetErrorHandler((request, exception) => new JsonRpcException(-32000, "Server error", exception.data)); -Config.SetParseErrorHandler((rawJson, exception) => exception); -Config.SetPreProcessHandler((request, context) => null); // return a JsonRpcException to reject; may replace Method/Params/Id -Config.SetPostProcessHandler((request, response, context) => null); // return a JsonRpcException to replace the result -``` +// Process-wide +Config.SetSerializer(new SystemTextJsonRpcSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); -Registering a pre- or post-process handler switches the affected session onto a slower path that materialises `JsonRequest`/`JsonResponse` objects for the handler; leave them unset when you do not need them. +// Per session +Config.SetSerializer("legacy-clients", new NewtonsoftJsonRpcSerializer(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore })); -The errors the library raises itself carry structured `data`, identical for every serializer, and the error handler receives the same object: +// Per call +string json = JsonRpcProcessor.ProcessSync(sessionId, request, context, serializer); +``` -| Code | `error.data` | Object seen by the error handler | +The built-in serializer is the default. All three serializers write the envelope, primitives, dates and plain objects the same way (member order, `.0` on whole floats, ISO dates, nulls written), and the test suite runs its protocol cases against each of them. They are not interchangeable for every request: the coercions they accept, the CLR types they support and the object model they hand to handlers differ, so test client-visible requests and responses before switching. Library options go into the serializer's constructor and nowhere else: + +| Serializer | Constructor | Notes | | --- | --- | --- | -| `-32601` Method not found | `{"method":""}` | `MethodNotFoundInfo` | -| `-32602` Invalid params: count, missing, unknown or repeated named parameter | a sentence, e.g. `"Named parameter 'b' was not present."` | `string` | -| `-32602` Invalid params: a value the serializer could not convert | `{"reason":"conversion","parameter":"b","index":1,"expectedType":"int32"}` plus `"message"` when `Config.IncludeExceptionDetails` is on; the value sent is never echoed | `ParameterErrorInfo` (with the serializer's exception in `Cause`) | -| `-32603` Internal error: the method threw, or a parameter's type is one the serializer cannot handle | `{ClassName, Message, ...}`, see [Exception details](#exception-details) | `Exception` | +| built-in | `new JsmnSerializer(lenient: false, maxDepth: 64)` | `lenient` accepts `'single quotes'`, unquoted keys and trailing commas | +| Json.NET | `new NewtonsoftJsonRpcSerializer(settings)` | one `JsonSerializer` is built from the settings and reused; input is always lenient | +| System.Text.Json | `new SystemTextJsonRpcSerializer(options)` | the package adds its wire-format converters to a copy of your options when they are missing | -"Could not convert" means the serializer refused the value (`JsonRpcBindException`, `FormatException`, `OverflowException`, `InvalidCastException`, or any `JsonException` from System.Text.Json or Json.NET); what each serializer accepts (say `"7"` for an `int`) is its own decision, see [docs/serializers.md](docs/serializers.md). +The full contract, what the core fixes versus what a serializer decides, is in [docs/serializers.md](docs/serializers.md). -### Asynchronous methods +### Nesting depth -Methods may return `Task`, `Task`, `ValueTask` or `ValueTask`. Call `JsonRpcProcessor.ProcessAsync` to await the operation and serialize its eventual result; the non-generic forms answer JSON `null`. Completed operations run inline. The byte overloads return `Task.CompletedTask` when the whole document completes successfully inline. Batches execute sequentially, and notifications are awaited too. +Every serializer exposes `MaxDepth` (default 64). A request nested deeper is answered `-32700` before any handler or binding runs, so recursive parameter conversion is bounded by the same number the JSON library itself enforces: the built-in serializer's constructor argument, `JsonSerializerOptions.MaxDepth`, or `JsonSerializerSettings.MaxDepth`. -```csharp -[JsonRpcMethod("lookup")] -public async Task Lookup(int id, [JsonRpcCancellation] CancellationToken cancellationToken) - => await repository.FindAsync(id, cancellationToken).ConfigureAwait(false); +### The `jsonrpc` member -await JsonRpcProcessor.ProcessAsync(sessionId, requestMemory, output, - context: requestContext, cancellationToken: cancellationToken); -string response = await JsonRpcProcessor.ProcessAsync(sessionId, requestJson, - context: requestContext, cancellationToken: cancellationToken); +```csharp +Config.VersionPolicy = JsonRpcVersionPolicy.Lenient; // process default +Config.SetVersionPolicy("strict-clients", JsonRpcVersionPolicy.Strict); // per session; null follows the process default ``` -The byte APIs accept `ReadOnlyMemory`, `ReadOnlySequence` (by value), or `ReadOnlySpan`. Keep borrowed request bytes immutable and valid and the output writer exclusive until the task completes. The span overload copies before returning; segmented sequences are copied too. No output spans are held across awaits. The task covers response writing, not transport flushing. +| Policy | Missing member | `"2.0"` | Anything else | +| --- | --- | --- | --- | +| `Lenient` (default) | accepted | accepted | `-32600 Invalid Request` | +| `Ignore` | accepted | accepted | accepted | +| `Strict` | `-32600 Invalid Request` | accepted | `-32600 Invalid Request` | -The processor token is injected only into a `[JsonRpcCancellation] CancellationToken` parameter; it is excluded from JSON parameters and SMD. An unmarked token parameter is rejected at registration. Synchronous methods may also request injection through `ProcessAsync`; ordinary synchronous processing passes the default token. Cancellation is checked before invocation, between batch elements, and before the staged document is committed. It discards the entire staged response, observes an in-flight operation to completion, releases its resources, and returns a canceled task. A method that ignores the token can therefore delay cancellation. Cancellation cannot undo service side effects. A method's own `OperationCanceledException` follows ordinary error mapping when the processor token has not been canceled. +The default keeps tool harnesses that omit the member working while a client speaking another version is told so. `Ignore` is for talking to anything at all. -Async methods default to `RpcContextFlow.None`: the ambient accessors (`Handler.RpcContext()`, `Handler.RpcRequestId()`, `JsonRpcContext.Current().Value`, `Handler.RpcSetException()`) are valid in the synchronous part of the method, before its first suspension, and an operation that completes inline costs the same as a synchronous call and allocates nothing. Opt a method into `RpcContextFlow.Flow` when it needs the ambient context after an await; then the accessors work across sequential awaits and nested dispatch, each invocation owns a frame that is cleared at terminal completion, and every invocation pays an allocation for the execution-context bridge, completed tasks included: +## Security -```csharp -[JsonRpcMethod(ContextFlow = RpcContextFlow.Flow)] -public async Task Lookup(int id) -{ - var item = await repository.FindAsync(id); - if (item == null) Handler.RpcSetException(new JsonRpcException(-32000, "Not found", null)); - return item; -} +What the library does by default: -ServiceBinder.BindMethod(sessionId, "lookup", new Func>(LookupAsync), - contextFlow: RpcContextFlow.Flow); -``` +- **Exception details are off.** An unhandled exception reaches the client as `-32603` with its type name and message only; the message is always sent, so rewrite sensitive messages in an error handler. `Config.IncludeExceptionDetails = true` adds the stack trace, source, HResult and inner exceptions; use it in development only. See [Exception disclosure](#exception-disclosure). +- **Rejected values are not echoed.** A `-32602` conversion error names the parameter and the expected type, never the value sent. +- **Nesting is limited to 64 levels.** A deeper request is `-32700` before any of your code runs. +- **Request size is limited on the Kestrel host only.** `MaxRequestBytes` defaults to 4 MB: HTTP answers `413`, a raw connection is aborted. The core itself does not limit document length; that is the transport's job. There is no limit on how many requests a batch holds, no response-size limit and no request deadline; a batch runs sequentially, so a 4 MB batch of small requests ties up one request's worth of server time for all of them. +- **Every `[JsonRpcMethod]` is callable.** Visibility does not matter (private methods are exposed), and `AddJsonRpcServicesFromAssembly` exposes every class in the assembly that carries the attribute. +- **Sessions are created on lookup and kept.** An unknown session id creates a session that lives until it is destroyed; see [Sessions and context](#sessions-and-context). +- **Cancellation is cooperative.** It waits for a running method and cannot undo what the method already did. -In `None` mode the initial synchronous portion can capture the context and an owned `JsonRpcRequestId` snapshot; ambient state does not flow after suspension. Throw authored errors instead of setting ambient error state after an await. For parallel child branches in either mode, capture snapshots and avoid sharing the mutable ambient frame. Background work must retain snapshots because the invocation frame is cleared when the RPC finishes. Use raw ID spans only during the immediate call that reads them; reacquire them after awaiting. +What it leaves to you: -`async void`, custom awaitables, nested awaitables and asynchronous streams are rejected at registration. Async registrations cannot have by-ref parameters, including the legacy trailing `ref JsonRpcException`. A null returned `Task` is an internal error. Synchronous `Process`/`ProcessSync` reject an async registration at call time with `-32603` and an instruction to use `ProcessAsync`, without invoking it. The older `Task Process(...)` family retains its scheduled synchronous execution through `Task.Factory.StartNew`; it does not await async service methods. +- **Authentication and authorisation.** On HTTP, use endpoint metadata: `app.MapJsonRpc("/rpc").RequireAuthorization("api")`. A raw connection has none; listen on loopback or a Unix socket, or authenticate in front of it. +- **Per-method authorisation.** Check `Handler.RpcContext()` (the `HttpContext` on HTTP) inside the method, or reject in a pre-process handler (which moves the session to the slower path). +- **Transport security, rate limiting and deadlines.** TLS, rate limits and timeouts are Kestrel's and the middleware pipeline's, not this library's. Raw connections bypass the HTTP middleware and need equivalent controls at the listener. +- **Service state.** One service instance serves every request concurrently; see [Classes](#classes). -A job ticket remains useful for work that should outlive the request: +The `jsonrpc` member policy (`Lenient` by default) is a compatibility setting, not a control; see [The `jsonrpc` member](#the-jsonrpc-member). -```csharp -[JsonRpcMethod] private string startExport(string filter) { var job = Jobs.Start(() => ExportAsync(filter)); return job.Id; } -[JsonRpcMethod] private ExportStatus exportStatus(string jobId) => Jobs.Status(jobId); -``` +## Benchmarks -The client gets a ticket immediately and polls, or the transport pushes a notification when the job finishes. +| What | RPC/s | Details | +| --- | ---: | --- | +| Library alone, 16 threads | 30.6 M to 35.8 M | [Sync](#sync-the-library-alone) | +| Kestrel TCP, 256 pipelined | 15.2 M to 15.5 M | [Kestrel](#kestrel-through-the-aspnetcore-package) | +| Kestrel HTTP, batch of 100 per POST | 12.7 M to 13.7 M | | +| Kestrel HTTP, one request per POST | 128 k to 168 k | HTTP/1.1 round trips dominate | -## Benchmarks +All numbers below are from an AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11, .NET 10, Release, Server GC, with the built-in serializer, measured 2026-09-23. Where a row gives two figures they are the spread over that day's runs on an otherwise idle machine; the WSL virtual machine, which takes 15 to 25 % of the box when idle, was shut down for the Kestrel and comparison runs. A single benchmark thread on this machine varies with whatever else lands on its core's SMT sibling, so the 1-thread rows are from runs on an idle core. -`TestServer_Console` is the benchmark harness. It binds one service with five small methods (`add`, `addInt`, `NullableFloatToNullableFloat`, `Test2`, `StringMe`), drives the same five requests through the server, checks every response is a `result` rather than an error, and ends each mode with a bar chart of RPC/s. +`TestServer_Console` is the benchmark harness. It binds one service with five small methods (`add`, `addInt`, `NullableFloatToNullableFloat`, `Test2`, `StringMe`), drives the same five requests through the server, checks every response is a `result` rather than an error, and ends each mode with a bar chart of RPC/s. For one-request timings with an allocation column, the numbers to check before merging a change to the dispatch path, see [benchmarks/Micro](benchmarks/Micro/README.md). ``` -dotnet run -c Release --project TestServer_Console -- --async 3 1 # real async invocation, Flow and None separately dotnet run -c Release --project TestServer_Console -- --sync 3 # library only, 1..N threads (add a thread count, e.g. --sync 3 1, for one row) +dotnet run -c Release --project TestServer_Console -- --async 3 1 # ProcessAsync, Flow and None separately; no published figures yet dotnet run -c Release --project TestServer_Console -- --kestrel 3 # through the AspNetCore package, HTTP and TCP dotnet run -c Release --project TestServer_Console -- --compare 3 # the same calls through StreamJsonRpc and gRPC for .NET, side by side dotnet run -c Release --project TestServer_Console -- --sweep 2 benchmarks/charts/sweep.json # every library and transport at 1, 2, 4, 8, 16 connections; one file per run @@ -350,7 +495,7 @@ dotnet run -c Release --project TestServer_Console # menu: Ente dotnet run --project samples/WasmHost # browser: "Run benchmark" on the page ``` -All numbers below are from an AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11, .NET 10, Release, Server GC, with the built-in serializer, measured 2026-09-23. Where a row gives two figures they are the spread over that day's runs on an otherwise idle machine; the WSL virtual machine, which takes 15 to 25 % of the box when idle, was shut down for the Kestrel and comparison runs. A single benchmark thread on this machine varies with whatever else lands on its core's SMT sibling, so the 1-thread rows are from runs on an idle core. +`--async [seconds] [workers]` uses `ProcessAsync` with separate sessions and the same five wire names as `--sync`. It reports synchronous returns through the new API, completed `Task` and inline `ValueTask`, with Flow and None registrations reported separately, plus a `yieldsOnce` shape that awaits `Task.Yield()`. Responses and ids are validated before timing, and allocation totals include the service methods' own allocations. ### Sync: the library alone @@ -371,12 +516,6 @@ All numbers below are from an AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz Per-thread cost rises with thread count because the 16 threads share 8 physical cores. -The 2026-09-23 performance pass (compiled invokers that read the tokens and write the pooled buffer through direct calls instead of virtual, delegate and interface calls; a tokenizer that keeps its scanner state in locals; a last-session cache; envelope keys matched by length; a flat method table) was measured A/B in one session: the same seven runs of `--sync 2 1` went from 3.2 M to 4.1 M (median 3.6 M) before to 4.0 M to 4.8 M (median 4.4 M) after, about 20 to 25 % more on one thread. The transport rows below are bound by the loopback round trips rather than by the library and moved less. - -### Asynchronous invocation - -`--async [seconds] [workers]` uses `ProcessAsync` with separate sessions and the same five wire names as `--sync`. It reports synchronous returns through the new API, completed `Task`, and inline `ValueTask`, with Flow and None registrations reported separately. A separate `yieldsOnce` shape awaits `Task.Yield()`. Responses and IDs are validated before timing. Inline allocation totals use current-thread accounting; the timed runs also report process-wide allocations to include suspended continuations. These totals include allocations made by the service methods. No throughput figures are published here yet. - ### Task: scheduled synchronous execution The default mode submits batches through the `Task`-returning `Process` overload from every core at once, the way an async host would, so it pays for the thread-pool hop, a `Task`, a result string and a continuation per request. Each batch size is repeated for at least half a second after a one-second warm-up. Throughput peaks once a batch is large enough to keep every core busy and falls off again when hundreds of thousands of requests are queued at once: @@ -390,7 +529,7 @@ The default mode submits batches through the `Task`-returning `Process` overload | 252,000 | 7.6 M | | 2,016,000 | 7.2 M | -Sync beats Task mode because Task mode measures the .NET thread pool and per-request garbage as much as the library; Kestrel awaits transport reads and flushes without dedicating a thread to each connection. +Task mode is slower than sync mode because it measures the .NET thread pool and a `Task`, a string and a continuation per request as much as the library. The AspNetCore host does not use that string path or allocate a result string: it awaits transport reads and flushes without a thread per connection, as the next table shows. ### Kestrel: through the AspNetCore package @@ -428,7 +567,9 @@ The TCP client keeps 256 requests in flight per connection and refills from a pr | gRPC for .NET, unary calls over HTTP/2 (Grpc.Net.Client, 16 channels × 256 in flight) | 192 k to 198 k | | gRPC for .NET, one bidirectional stream per channel, 256 in flight, batched writes | 200 k to 209 k | -StreamJsonRpc 2.25.29, defaults apart from the formatter and framing named in each row. It is a full bidirectional RPC framework (client proxies, cancellation, progress, marshaled objects, events). The comparison is of the server side answering the same five requests; on that measure JSON-RPC.Net is about 10× faster on the same connections with the same JSON library underneath. +StreamJsonRpc 2.25.29, defaults apart from the formatter and framing named in each row. It is a full bidirectional RPC framework (client proxies, cancellation, progress, marshaled objects, events). The comparison is of the server side answering the same five requests; on that measure JSON-RPC.Net is about 10× faster on the same connections. JSON-RPC.Net ran its built-in serializer, and the fastest StreamJsonRpc rows use System.Text.Json, so this is a comparison of whole server paths, not of one JSON library against itself; running JSON-RPC.Net with the System.Text.Json serializer is a separate measurement and is not in this table. + +gRPC for .NET 2.84.0 with default settings apart from Kestrel's `MaxStreamsPerConnection` (raised to 256 so the pipeline depth is not capped at 100). protobuf has no `decimal`, so `Test2` carries the units/nanos `DecimalValue` message the gRPC docs recommend; nullable values use proto3 `optional`. The gRPC rows are a different kind of measurement from the rows above them: there is no cheap raw client for HTTP/2 + protobuf, so the client is Grpc.Net.Client on the same 8 cores as the server, and the figure is what a .NET caller and a .NET service get end to end. One channel alone reaches about 130 k unary calls per second; sixteen channels do not scale much further because client and server compete for the same cores. The streaming row batches its writes the way the TCP client does (BufferHint on every message but the last of a refill), and the server flushes only when its input runs dry, the same once-per-read-group flush `JsonRpcConnectionHandler` does.
In-process paths: a direct call, a Pipe pair and a typed proxy (different boundaries, not comparable with the rows above) @@ -448,8 +589,6 @@ StreamJsonRpc's server side has no "document in, document out" call, so its in-p
-gRPC for .NET 2.84.0 with default settings apart from Kestrel's `MaxStreamsPerConnection` (raised to 256 so the pipeline depth is not capped at 100). protobuf has no `decimal`, so `Test2` carries the units/nanos `DecimalValue` message the gRPC docs recommend; nullable values use proto3 `optional`. The gRPC rows are a different kind of measurement from the rows above them: there is no cheap raw client for HTTP/2 + protobuf, so the client is Grpc.Net.Client on the same 8 cores as the server, and the figure is what a .NET caller and a .NET service get end to end. One channel alone reaches about 130 k unary calls per second; sixteen channels do not scale much further because client and server compete for the same cores. The streaming row batches its writes the way the TCP client does (BufferHint on every message but the last of a refill), and the server flushes only when its input runs dry, the same once-per-read-group flush `JsonRpcConnectionHandler` does. - Every library and transport by client connections, 1 to 16: three panels on a shared log axis, one per library, with a marker shape and dash per setting and whiskers spanning two runs @@ -467,32 +606,49 @@ The [WasmHost sample](samples/WasmHost/README.md) compares JSON-RPC through JS i simdjson was evaluated as a fourth parser and not adopted: through the only maintained .NET binding its parse alone costs more than the whole built-in envelope read, and walking the result is 5 to 6 times slower with 350 bytes or more of garbage per request. The harness and numbers are in [benchmarks/SimdJsonEval/RESULTS.md](benchmarks/SimdJsonEval/RESULTS.md). -The charts, the explorer page and the figures in this file come from one data file, [benchmarks/charts/benchmarks.json](benchmarks/charts/benchmarks.json): the tables above transcribed with their published precision and conditions, plus the `--sweep` run files. `python benchmarks/charts/render.py` (plain Python, no packages) renders every chart in a light and a dark variant, which the README picks between with a `` element, and `render.py --check` fails if a committed chart is stale or a figure in this file no longer matches the data; the pull-request build runs it. GitHub serves README images through a proxy as plain ``, so the SVGs carry no scripts, hover text or links, and every range is drawn as an interval with its figures beside it; the interactive parts live on the explorer page. +### History + +The charts, the explorer page and the figures in this file come from one data file, [benchmarks/charts/benchmarks.json](benchmarks/charts/benchmarks.json); how they are rendered and checked is under [Building](#charts). -For comparison, under the previous harness (one pass per batch, workstation GC) the two-million batch ran at about 525,000 RPC/s on 1.3 and 1,584,906 RPC/s on 2.0 on the same machine. (The 1.x figure published earlier in this README was measured while the benchmark service was not bound, so every request took the "method not found" path; the benchmark now prints the responses so that cannot go unnoticed.) +The 2026-09-23 performance pass (compiled invokers that read the tokens and write the pooled buffer through direct calls instead of virtual, delegate and interface calls; a tokenizer that keeps its scanner state in locals; a last-session cache; envelope keys matched by length; a flat method table) was measured A/B in one session: the same seven runs of `--sync 2 1` went from 3.2 M to 4.1 M (median 3.6 M) before to 4.0 M to 4.8 M (median 4.4 M) after, about 20 to 25 % more on one thread. The transport rows are bound by the loopback round trips rather than by the library and moved less. + +Under the previous harness (one pass per batch, workstation GC) the two-million batch ran at about 525,000 RPC/s on 1.3 and 1,584,906 RPC/s on 2.0 on the same machine. The 1.x figure published earlier in this README was measured while the benchmark service was not bound, so every request took the "method not found" path; the benchmark now prints the responses so that cannot go unnoticed. ## Upgrading from 1.x -- `JsonRpcProcessor.Process(…, JsonSerializerSettings)` is gone from the core. Use `Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings))` from the Newtonsoft package. -- The default-session string overloads that take a serializer take it first: `Process(serializer, json, context)` / `ProcessSync(serializer, json, context)`. The session overloads keep `(sessionId, json, context, serializer)` with `context` required, so `Process(json, null)` still means the default session. -- `JsonRequest`, `JsonResponse` and `JsonRpcException` are plain DTOs without Json.NET attributes. `JsonRequest.Params` is the active serializer's object model, so cast to `JObject`/`JArray` only when the Json.NET serializer is active. -- The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is still accepted, but `"jsonrpc":"1.0"` or a non-string value is now `-32600`. Set `Ignore` for the 1.x behaviour. -- Requests nested deeper than 64 levels are `-32700` (configurable per serializer, see [Nesting depth](#nesting-depth)). Invalid UTF-8 and non-strict JSON (unless the serializer is lenient) are `-32700` as well. -- The empty-batch error code is the spec's `-32600` (it was `3200`). Batches made only of notifications produce an empty response instead of `[]` with a dangling comma. -- A batch always answers with a JSON array when it produces at least one response; a one-request batch is no longer unwrapped to a bare response object. -- A notification (a request without an `id`) never gets a wire response, whatever its outcome: method not found, binding failure or an exception in the method produce nothing on the wire (the error handler still runs server-side). An invalid request object is not a notification and still gets `-32600` with `"id":null`. -- Exception details are redacted by default; see [Exception details](#exception-details). -- Task-returning methods are supported again through `ProcessAsync`, together with `ValueTask` and `ValueTask`. Synchronous `Process`/`ProcessSync` reject them at call time without invoking them. `async void` remains rejected at registration. See [Asynchronous methods](#asynchronous-methods). -- A parameter value the serializer cannot convert (`"abc"` for an `int`, `"not-a-guid"` for a `Guid`) is `-32602` with `data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…}`; it was `-32603` with the exception. An exception of the same type thrown inside the method is still `-32603`. A type the built-in serializer cannot handle at all stays `-32603` (now a `NotSupportedException`). -- `-32601`'s `data` is `{"method":""}` instead of the fixed sentence, and a method-not-found error for a notification now reaches the error handler (the wire still gets nothing). -- The invocation frame also carries the request id: `Handler.RpcRequestId()` / `JsonRpcContext.CurrentRequestId()`, `Handler.RpcRequestIdKind()` and `Handler.RpcRequestIdRaw()`, see [Sessions and context](#sessions-and-context). -- `ServiceBinder.BindMethod(sessionId, name, delegate)` registers any delegate; it refuses a name that is already registered, unlike `Handler.RegisterFuction`, which keeps replacing silently. -- Named parameters are checked against the method's parameter list: a supplied name that matches no parameter, or a name supplied twice, is `-32602` (it used to be ignored, so `optional(int a = 9)` called with `{"typo":4}` returned 9). Defaults fill only the names that are absent. -- `SMD.Services` is an `SMDServiceCollection` (an `IDictionary`) instead of a `Dictionary`, and its setter is gone. Every mutation through it updates the dispatch table at once, so a removed method is unreachable immediately. -- `SMD.Types` is a process-wide registry (it was reset whenever a session was created). -- A pre-process handler may replace `JsonRequest.Method`, `Params` or `Id`; the replaced request is what gets dispatched (as in 1.x). Assign a new `Params` value rather than editing the serializer's object model in place: a request the handler leaves untouched is dispatched straight from the request bytes. -- `JsonRpcContext.Current()` / `Handler.RpcContext()` and `JsonRpcContext.SetException` are per invocation: a method that synchronously processes another request through `JsonRpcProcessor` gets its own context and exception state back afterwards. -- `DateTime` and `DateTimeOffset` are written the way Json.NET writes them by every serializer (fraction only when non-zero, `Z`/offset/nothing by `Kind`); `NaN` and the infinities are written as the quoted strings `"NaN"`, `"Infinity"`, `"-Infinity"` and read back from them. +Most 1.x services run unchanged. Read the first list before you build, and the second before you deploy next to existing clients. + +### Changes that break the build + +- **Serializer.** `JsonRpcProcessor.Process(…, JsonSerializerSettings)` is gone from the core. Use `Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings))` from the Newtonsoft package, or the helper overloads there that take the settings. +- **Overloads.** The default-session string overloads that take a serializer take it first: `Process(serializer, json, context)` and `ProcessSync(serializer, json, context)`. `ProcessSync(sessionId, json, context, serializer)` makes `context` required, so `ProcessSync(json, null)` still means the default session. `Process` and `ProcessAsync` do not: `Process(json, null)` no longer compiles (it is ambiguous with the `JsonRpcStateAsync` overload), and `ProcessAsync(json, null)` binds to the session overload with `json` as the session id and a null document, which throws `ArgumentNullException`. Write `Process(json)`, `Process(json, context: null)` or `ProcessAsync(json, context: null)`. +- **DTOs.** `JsonRequest`, `JsonResponse` and `JsonRpcException` are plain DTOs without Json.NET attributes. `JsonRequest.Params` is the active serializer's object model, so cast to `JObject`/`JArray` only when the Json.NET serializer is active. +- **SMD.** `SMD.Services` is an `SMDServiceCollection` (an `IDictionary`) instead of a `Dictionary`, and its setter is gone. Every mutation through it updates the dispatch table at once, so a removed method is unreachable immediately. `SMD.Types` is now `Dictionary>` and a process-wide registry (it was reset whenever a session was created). + +### Changes clients will see on the wire + +- **Version member.** The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is still accepted, but `"jsonrpc":"1.0"` or a non-string value is now `-32600`. Set `Ignore` for the 1.x behaviour. +- **Parse errors.** Requests nested deeper than 64 levels are `-32700` (configurable per serializer, see [Nesting depth](#nesting-depth)). Invalid UTF-8 and non-strict JSON (unless the serializer is lenient) are `-32700` as well. +- **Batches.** The empty-batch error code is the spec's `-32600` (it was `3200`). Batches made only of notifications produce an empty response instead of `[]` with a dangling comma. A batch always answers with a JSON array when it produces at least one response; a one-request batch is no longer unwrapped to a bare response object. +- **Notifications.** A notification (a request without an `id`) never gets a wire response, whatever its outcome: method not found, binding failure or an exception in the method produce nothing on the wire (the error handler still runs server-side). An invalid request object is not a notification and still gets `-32600` with `"id":null`. +- **Exceptions.** Stack traces, sources, HResults and inner exceptions are omitted by default, but the exception type and message are still returned; see [Exception disclosure](#exception-disclosure). +- **Conversion errors.** A parameter value the serializer cannot convert (`"abc"` for an `int`, `"not-a-guid"` for a `Guid`) is `-32602` with `data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…}`; it was `-32603` with the exception. An exception of the same type thrown inside the method is still `-32603`. A type the built-in serializer cannot handle at all stays `-32603` (now a `NotSupportedException`). +- **Method not found.** `-32601`'s `data` is `{"method":""}` instead of the fixed sentence, and a method-not-found error for a notification now reaches the error handler (the wire still gets nothing). +- **Named parameters.** They are checked against the method's parameter list: a supplied name that matches no parameter, or a name supplied twice, is `-32602` (it used to be ignored, so `optional(int a = 9)` called with `{"typo":4}` returned 9). Defaults fill only the names that are absent. +- **Dates and non-finite numbers.** `DateTime` and `DateTimeOffset` are written the way Json.NET writes them by every serializer (fraction only when non-zero, `Z`/offset/nothing by `Kind`); `NaN` and the infinities are written as the quoted strings `"NaN"`, `"Infinity"`, `"-Infinity"` and read back from them. + +### Behaviour inside your server + +- **Async methods.** Task-returning methods are supported again through `ProcessAsync`, together with `ValueTask` and `ValueTask`. Synchronous `Process`/`ProcessSync` reject them at call time without invoking them. `async void` remains rejected at registration. See [Asynchronous methods and cancellation](#asynchronous-methods-and-cancellation). +- **Request id.** The invocation frame also carries the request id: `Handler.RpcRequestId()` / `JsonRpcContext.CurrentRequestId()`, `Handler.RpcRequestIdKind()` and `Handler.RpcRequestIdRaw()`, see [The request id](#the-request-id). +- **Binding.** `ServiceBinder.BindMethod(sessionId, name, delegate)` registers any delegate; it refuses a name that is already registered, unlike `Handler.RegisterFuction`, which keeps replacing silently. +- **Pre-process handlers.** A pre-process handler may replace `JsonRequest.Method`, `Params` or `Id`; the replaced request is what gets dispatched (as in 1.x). Assign a new `Params` value rather than editing the serializer's object model in place: a request the handler leaves untouched is dispatched straight from the request bytes. +- **Context.** `JsonRpcContext.Current()` / `Handler.RpcContext()` and `JsonRpcContext.SetException` are per invocation: a method that synchronously processes another request through `JsonRpcProcessor` gets its own context and exception state back afterwards. +- **`Handler.Handle(JsonRequest)`** still works; it round-trips the request through the serializer and the boxed path. + +## Versioning and support + +The four 2.x packages are built from one repository and released together at one version number (2.0.0 at this writing); use matching versions. The targets that are tested are the ones listed under [Requirements](#requirements). Breaking changes are listed under [Upgrading from 1.x](#upgrading-from-1x) and in each package's release notes on NuGet. Questions and bugs go to [GitHub issues](https://github.com/Astn/JSON-RPC.NET/issues). ## Building @@ -505,13 +661,12 @@ dotnet test AustinHarris.JsonRpcTestN The test suite runs its protocol cases once per serializer (built-in, Json.NET, System.Text.Json) plus the parser, dispatch, version-policy and Kestrel integration tests, on both `net8.0` and `net10.0`. Building a package project in Release produces its NuGet package in `bin/Release/`. The WebAssembly sample builds without the `wasm-tools` workload; add it for AOT. -##### License -JSON-RPC.net is licensed under The MIT License (MIT), check the [LICENSE](https://github.com/Astn/JSON-RPC.NET/blob/master/LICENSE) file for details. +`AustinHarris.JsonRpc.Client`, `AustinHarris.JsonRpc.Client.WP7`, `AustinHarris.JsonRpc.AspNet`, `JsonRpcTest` and `TestClient` are 1.x projects that are still in the tree but outside the solution; nothing in 2.0 is built from them. -##### Documentation +### Charts -This README and the package guides are also published as a site at [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RPC.NET/), built from the same files. +The charts, the explorer page and the benchmark figures in this README come from one data file, [benchmarks/charts/benchmarks.json](benchmarks/charts/benchmarks.json): the tables transcribed with their published precision and conditions, plus the `--sweep` run files. `python benchmarks/charts/render.py` (plain Python, no packages) renders every chart in a light and a dark variant, which the README picks between with a `` element, and `render.py --check` fails if a committed chart is stale or a figure in this README no longer matches the data; the pull-request build runs it. GitHub serves README images through a proxy as plain ``, so the SVGs carry no scripts, hover text or links, and every range is drawn as an interval with its figures beside it; the interactive parts live on the explorer page. -##### Old Project Site +## License -We have to github lately and host our [issues section](https://github.com/Astn/JSON-RPC.NET/issues) here, though you can still check the previous [issues](https://jsonrpc2.codeplex.com/workitem/list/basic) and [discussions](https://jsonrpc2.codeplex.com/discussions) over our old project site. +MIT. See [LICENSE](LICENSE). diff --git a/benchmarks/Micro/README.md b/benchmarks/Micro/README.md index a8670e0..821f8ee 100644 --- a/benchmarks/Micro/README.md +++ b/benchmarks/Micro/README.md @@ -1,18 +1,18 @@ # Micro-benchmarks -BenchmarkDotNet timings of one request on one core, per request shape, with an allocation column. Use these to judge a change to the dispatch path; use `TestServer_Console --sync` for throughput under load and the README tables. +BenchmarkDotNet timings of one request on one core, per request shape, with an allocation column. Use these to judge a change to the dispatch path; use `TestServer_Console --sync` for throughput under load and the tables in the [main README](../../README.md#benchmarks). ```bash dotnet run -c Release --project benchmarks/Micro -- --filter '*' ``` -Useful arguments: `--filter '*Dispatch*'` for one class, `--job short` for a quick look (3 warm-up and 3 measured iterations), `--disasm` to print the JIT disassembly of each benchmark. +Useful arguments: `--filter 'AustinHarris.JsonRpc.Micro.DispatchBenchmarks.*'` for one class (`*Dispatch*` would also match `AsyncDispatchBenchmarks`), `--job short` for a quick look (3 warm-up and 3 measured iterations), `--disasm` to print the JIT disassembly of each benchmark. -Rows: +Benchmark classes: - `DispatchBenchmarks`: the five shapes of the console harness (`add`, `addInt`, nullable float, decimal, string), a batch of the five, and a notification, through `JsonRpcProcessor.Process` with the built-in serializer and a class registered with `[JsonRpcMethod]`. - `InterfaceBindingBenchmarks`: the same five shapes through a contract registered with `ServiceBinder.BindInterface`, plus one and two levels of interface-typed properties (`Calc.addInt`, `Admin.Calc.addInt`). Interface rows should match the class rows of `DispatchBenchmarks`; the tree rows pay only for the longer method name. - `BindingComparisonBenchmarks`: `addInt`, decimal and string through the same class registered with `[JsonRpcMethod]` and through `BindInterface`, in one process, so a drift of the machine between runs cannot masquerade as a binding cost. - `AsyncDispatchBenchmarks`: a synchronous method through `Process` and `ProcessAsync`, `Task` and `ValueTask` methods that complete inline, and a method that yields once, each with the default `RpcContextFlow.None` and with `RpcContextFlow.Flow`. The inline default rows should allocate nothing; the `Flow` rows pay for the execution-context bridge; the yielding rows show the cost of a real suspension. -Read the `Allocated` column first: a non-zero value on a numeric shape means the request touched the GC, which the fast path must not do. Then compare `Mean` between two builds run under the same load; on a busy machine the ratio is meaningful, the absolute number is not. +Read the `Allocated` column first: a non-zero value on a numeric shape means the request touched the GC, which the fast path must not do. Then compare `Mean`, but only between runs on an idle machine or within one run: background load biases ratios as well as absolute numbers, which is why `BindingComparisonBenchmarks` puts both registrations in one process. diff --git a/docs/serializers.md b/docs/serializers.md index 5e17f81..82fa618 100644 --- a/docs/serializers.md +++ b/docs/serializers.md @@ -4,12 +4,12 @@ JSON-RPC.Net 2.0 splits the work in two. The **core** owns the JSON-RPC envelope `params` and `id`, resolves the method, binds parameters, invokes, and writes `{"jsonrpc":"2.0","result":…,"id":…}` or the error object. A **serializer** owns values only: it turns the raw bytes of one JSON value into a CLR value, and a CLR value into JSON bytes. Nothing from a -JSON library leaks into the core, so Json.NET, System.Text.Json and the built-in serializer are -interchangeable and each ships as its own package. +JSON library leaks into the core, so Json.NET, System.Text.Json and the built-in serializer plug into +the same slot and each ships as its own package. | Package | Serializer | Default? | Notes | |---|---|---|---| -| `AustinHarris.JsonRpc` | `Jsmn.JsmnSerializer` | yes | no dependencies; span port of the jsmn tokenizer plus a reflection mapper with cached type plans; primitives and `Nullable` bind without boxing | +| `AustinHarris.JsonRpc` | `Jsmn.JsmnSerializer` | yes | no JSON library; span port of the jsmn tokenizer plus a reflection mapper with cached type plans; primitives and `Nullable` bind without boxing | | `AustinHarris.JsonRpc.Newtonsoft` | `Newtonsoft.NewtonsoftJsonRpcSerializer` | | Json.NET 13; lenient input; honours `JsonSerializerSettings`; the compatibility choice for code that relied on Json.NET behaviour | | `AustinHarris.JsonRpc.SystemTextJson` | `SystemTextJson.SystemTextJsonRpcSerializer` | | `Utf8JsonReader`/`Utf8JsonWriter` directly on the request bytes; honours `JsonSerializerOptions` | @@ -20,7 +20,8 @@ public abstract class JsonRpcSerializer { public abstract string Name { get; } public virtual bool Lenient => false; // envelope reader accepts 'single quotes', bare keys, trailing commas - public virtual JsonRpcRequestReader CreateReader(); // envelope cursor; default is the jsmn tokenizer + public virtual int MaxDepth => 64; // nesting limit enforced by the envelope reader + public virtual JsonRpcRequestReader CreateReader(); // envelope cursor; the base implementation uses the jsmn tokenizer public abstract T Read(ReadOnlySpan utf8Json); // exactly one JSON value in, T out public abstract object Read(ReadOnlySpan utf8Json, Type type); public abstract void Write(IBufferWriter output, T value); @@ -41,10 +42,10 @@ path; `object[]` and `DynamicInvoke` are gone. ## Choosing a serializer: three levels -Resolution order for every call is: **per-call argument** → **session** → **global** → built-in. +Resolution order for every call is: **per-call argument** → **session** → **process-wide** → built-in. ```csharp -// 1. Global default (process-wide; volatile, takes effect for subsequent calls) +// 1. Process-wide default (volatile, takes effect for subsequent calls) Config.SetSerializer(new SystemTextJsonRpcSerializer()); Config.Serializer = null; // back to the built-in serializer @@ -58,16 +59,21 @@ JsonRpcProcessor.Process(sessionId, requestBytes, output, context, serializer); ``` Use per-session when different endpoints of one process serve different clients (a strict -System.Text.Json API next to a lenient Json.NET one for old clients). Use per-call when the transport -negotiates it (a header, a route, a protocol version). The global default is for the common case of -one serializer everywhere. - -Serializers must be thread-safe and are meant to be long-lived: construct one, share it. The -synchronous processor pools an envelope reader per thread. `ProcessAsync` uses separate transferable -leases and a bounded shared pool; a fresh serializer per call defeats reader reuse. A custom reader -must keep the selected request and its backing storage valid until `Release`, which can run on a -continuation thread after the operation terminates. Reads and writes remain synchronous individual -calls: only the service invocation is awaited. No span is carried across an await. +System.Text.Json API next to a lenient Json.NET one for old clients; the AspNetCore package's +`MapJsonRpc(pattern, options)` overload maps one endpoint per session). Use per-call when the transport +negotiates it (a header, a route, a protocol version). The process-wide default is for the common case +of one serializer everywhere. This order applies to the serializer only; error and processing handlers +are per session, see the main README's [Configuration](../README.md#configuration) table. + +Construct a serializer once and share it: serializers must be thread-safe. The synchronous fast path +keeps an envelope reader in per-thread scratch storage, reused while the serializer instance stays the +same (a re-entrant call gets its own scratch instance), so a new serializer per call throws that reuse +away. `ProcessAsync` takes readers from a bounded, transferable pool instead. + +**If you write your own reader** (`CreateReader()`): under `ProcessAsync` a reader can be handed +between threads, and `Release` can run on a continuation thread after the call has finished. Keep the +selected request and its backing memory valid until `Release`. Parameter reads and result writes are +still synchronous, and only the service method is awaited, so no span is held across an await. ## Library-specific options @@ -75,41 +81,50 @@ Each package accepts its own library's options in its constructor and nowhere el | Serializer | Options type | Constructor | Notes | |---|---|---|---| -| jsmn | `bool lenient`, `int maxDepth` | `new JsmnSerializer(lenient: true, maxDepth: 64)` | `lenient` accepts `'single quotes'`, unquoted keys and trailing commas in the request; `maxDepth` bounds nesting (default 64) | +| built-in | `bool lenient`, `int maxDepth` | `new JsmnSerializer(lenient: true, maxDepth: 64)` | `lenient` accepts `'single quotes'`, unquoted keys and trailing commas in the request; `maxDepth` bounds nesting (default 64) | | Json.NET | `JsonSerializerSettings` | `new NewtonsoftJsonRpcSerializer(settings)` | one `JsonSerializer` is created from the settings and reused; `Lenient` is always on | | System.Text.Json | `JsonSerializerOptions` | `new SystemTextJsonRpcSerializer(options)` | the package adds its wire-format converters (see below) to a copy of your options when they are missing | ### Nesting depth Every serializer exposes `MaxDepth` (virtual on `JsonRpcSerializer`, default 64). The envelope reader -rejects a request deeper than that with `-32700` before any hook or binding runs, so recursive parameter -conversion is bounded by the same number the JSON library itself enforces: for jsmn it is the constructor -argument, for System.Text.Json it is `JsonSerializerOptions.MaxDepth` and for Json.NET it is +rejects a request deeper than that with `-32700` before any handler or binding runs, so recursive parameter +conversion is bounded by the same number the JSON library itself enforces: for the built-in serializer it is +the constructor argument, for System.Text.Json it is `JsonSerializerOptions.MaxDepth` and for Json.NET it is `JsonSerializerSettings.MaxDepth` (both 64 when unset). The root object and the `params` container each count as one level. -The 1.x `JsonSerializerSettings` parameter on `JsonRpcProcessor.Process*` is gone from the core -because the core no longer references Json.NET. The Newtonsoft package provides the same overloads as -static helpers that build a `NewtonsoftJsonRpcSerializer(settings)` and forward to the core. - -## What the core fixes and what the serializer decides - -| Fixed by the core (identical for every serializer) | Decided by the serializer | -|---|---| -| envelope member order `jsonrpc, result|error, id`; compact output | how a result value / parameter / `error.data` is written and read | -| error object `{"code":…,"message":…,"data":…}` with `data` always present (null when absent) | POCO member naming and ordering (all three follow declaration order, PascalCase, nulls written) | -| id echoed byte-for-byte; `null` when the request had none or was invalid | numeric formatting (all three write whole float/double/decimal with `.0`) | -| batch shape: an array of responses whenever the batch produced one (a single response stays wrapped); a batch of notifications only produces nothing | coercions (number→bool, number→char, integer→float/decimal, ISO string→DateTime) | -| notifications (no `id`) never get a response, whatever the outcome; an invalid request object is not a notification and gets `-32600` with `"id":null` | leniency of the *values* (Json.NET accepts single-quoted strings; the others do not) | -| error codes: -32700 parse, -32600 invalid request/id, -32601 method (`data = {"method":…}`), -32602 params (missing/extra/count, unknown or repeated named parameter, or a value the serializer could not convert: `data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…}`), -32603 method exception or a type the serializer cannot handle | how `JsonRequest.Params` looks to pre/post handlers (`JObject`/`JArray`, `JsonElement`, or `Dictionary`/`List`) | -| what counts as "could not convert": `JsonRpcBindException`, `FormatException`, `OverflowException`, `InvalidCastException` and any `JsonException` family (System.Text.Json's, Json.NET's) thrown while reading an argument | which values convert at all (Json.NET and the built-in serializer read `7` as the string `"7"` and `true` as `1`; System.Text.Json refuses the number) | -| `Exception` in `error.data` normalised to `ExceptionInfo {ClassName, Message, Source, StackTraceString, HResult, InnerException}`; `Source`, `StackTraceString`, `HResult` and `InnerException` are null/0 unless `Config.IncludeExceptionDetails` is true | | -| the error boundary: a hook, the materialisation of `JsonRequest.Params` for a hook, binding and the method itself all fail into a JSON-RPC error (`-32602` for an argument the serializer refused, `-32603` otherwise, unless the error handler maps it); nothing throws out of `JsonRpcProcessor` | | -| the request id as a method sees it (`Handler.RpcRequestId()` and friends): the reader's own JSON of the id, so a lenient single-quoted `'x'` reads as `"x"` | | -| case-insensitive envelope keys (`Method`, `ID`); exact-name matching of named parameters | case-insensitive member names inside POCOs (all three do this) | - -The built-in serializer reproduces Json.NET's conventions so that a switch is invisible on the -wire; the test suite runs all 163 cases against each serializer to keep that true. +## What the core decides and what the serializer decides + +### The core decides (the same for every serializer) + +- envelope member order (`jsonrpc`, then `result` or `error`, then `id`); compact output +- error object `{"code":…,"message":…,"data":…}` with `data` always present (null when absent) +- id echoed byte-for-byte; `null` when the request had none or was invalid +- batch shape: an array of responses whenever the batch produced one (a single response stays wrapped); a batch of notifications only produces nothing +- notifications (no `id`) never get a response, whatever the outcome; an invalid request object is not a notification and gets `-32600` with `"id":null` +- error codes: -32700 parse, -32600 invalid request/id, -32601 method (`data = {"method":…}`), -32602 params (missing/extra/count, unknown or repeated named parameter, or a value the serializer could not convert: `data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…}`), -32603 method exception or a type the serializer cannot handle +- what counts as "could not convert": `JsonRpcBindException`, `FormatException`, `OverflowException`, `InvalidCastException` and any `JsonException` family (System.Text.Json's, Json.NET's) thrown while reading an argument +- `Exception` in `error.data` normalised to `ExceptionInfo {ClassName, Message, Source, StackTraceString, HResult, InnerException}`; `Source`, `StackTraceString`, `HResult` and `InnerException` are null/0 unless `Config.IncludeExceptionDetails` is true +- the error boundary: request, binding, handler and method failures become JSON-RPC errors (`-32602` for an argument the serializer refused, `-32603` otherwise, unless the error handler maps it). Invalid arguments (a null document), cancellation, and exceptions from your own error handler, custom reader or output writer reach the caller +- the request id as a method sees it (`Handler.RpcRequestId()` and friends): the reader's own JSON of the id, so a lenient single-quoted `'x'` reads as `"x"` +- case-insensitive envelope keys (`Method`, `ID`); exact-name matching of named parameters + +### The serializer decides + +- how a result value, a parameter or `error.data` is written and read +- POCO member naming and ordering (all three follow declaration order, PascalCase, nulls written) +- numeric formatting (all three write whole float/double/decimal with `.0`) +- coercions (number→bool, number→char, integer→float/decimal, ISO string→DateTime) +- leniency of the *values* (Json.NET accepts single-quoted strings; the others do not) +- how `JsonRequest.Params` looks to pre/post handlers (`JObject`/`JArray`, `JsonElement`, or `Dictionary`/`List`) +- which values convert at all (Json.NET and the built-in serializer read `7` as the string `"7"` and `true` as `1`; System.Text.Json refuses the number) +- case-insensitive member names inside POCOs (all three do this) + +The three serializers align the envelope, primitives, dates and plain objects, and the test suite runs +its protocol cases against each of them. They are not behaviour-identical: accepted coercions, supported +CLR types, object models and custom options differ, so test client-visible requests and responses before +changing serializers. ## Bytes in, bytes out @@ -117,9 +132,14 @@ The native entry points take what a `PipeReader` gives you and write to what a ` `HttpResponse.BodyWriter` is: ```csharp -void Process(string sessionId, in ReadOnlySequence request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null); -void Process(string sessionId, ReadOnlyMemory request, IBufferWriter output, …); -void Process(string sessionId, ReadOnlySpan request, IBufferWriter output, …); +void Process(string sessionId, in ReadOnlySequence request, + IBufferWriter output, object context = null, JsonRpcSerializer serializer = null); + +void Process(string sessionId, ReadOnlyMemory request, + IBufferWriter output, object context = null, JsonRpcSerializer serializer = null); + +void Process(string sessionId, ReadOnlySpan request, + IBufferWriter output, object context = null, JsonRpcSerializer serializer = null); ``` Nothing is written for a notification. Responses are rendered into a per-thread pooled buffer (so a @@ -128,13 +148,19 @@ half-written result can be discarded when a method throws) and copied once into transports. The `string` overloads (`ProcessSync`, `Task Process`) transcode into the same pooled buffers at the edge. -## Migrating from 1.x +## Upgrading from 1.x + +The core no longer references Json.NET, so the 1.x `JsonSerializerSettings` parameter on +`JsonRpcProcessor.Process*` is gone. Replace it with `Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings))`, +or use the helper overloads in the +[Newtonsoft package](../AustinHarris.JsonRpc.Newtonsoft/README.md#settings-based-helpers-1x-compatibility), +which build that serializer and forward to the core. + +Two things are specific to serializers: a custom serializer's own conversion exceptions are recognised as +"the client's value is wrong" without any change to it, and it may throw `JsonRpcBindException` to say the +same explicitly; and a type the built-in serializer does not support, or a class without a parameterless +constructor, throws `NotSupportedException` from `Read` (it was `JsonRpcBindException`) and stays `-32603` +on the wire. -- Replace `Process(…, JsonSerializerSettings settings)` with either `Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings))` or the helper overloads in the Newtonsoft package. -- `JsonRequest`, `JsonResponse`, `JsonRpcException` no longer carry Json.NET attributes; they are plain DTOs. `JsonRequest.Params` is whatever the active serializer's object model is; cast to `JObject`/`JArray` only when the Json.NET serializer is active. -- `SMD.Types` is now `Dictionary>` and is a process-wide registry (it used to be reset every time a session was created). -- `Handler.Handle(JsonRequest)` still works; it round-trips the request through the serializer and the boxed path. -- The empty-batch error code is now the spec's `-32600` (it was `3200`). -- Batches: a trailing notification no longer leaves a dangling comma; a batch consisting only of notifications returns an empty string. -- A value the serializer cannot convert to the parameter's type is `-32602` with structured `data` (it was `-32603` carrying the exception); the serializers' own conversion exceptions are recognised without any change to a custom serializer, which may also throw `JsonRpcBindException` to say "the client's value is wrong". -- A type the built-in serializer does not support, or a class without a parameterless constructor, throws `NotSupportedException` from `Read` (it was `JsonRpcBindException`) and stays `-32603` on the wire. +For the complete list of protocol, error, batching, async, context and metadata changes, see +[Upgrading from 1.x](../README.md#upgrading-from-1x) in the main README. diff --git a/samples/WasmHost/README.md b/samples/WasmHost/README.md index adb6adb..64a5389 100644 --- a/samples/WasmHost/README.md +++ b/samples/WasmHost/README.md @@ -1,8 +1,9 @@ # WasmHost: JSON-RPC.Net running in the browser A standalone Blazor WebAssembly app that hosts a JSON-RPC.Net server inside the page. There is no HTTP -and no Kestrel: JavaScript passes request text to `JsonRpcProcessor.ProcessSync` through JS interop and -gets the response text back. +and no Kestrel: JavaScript hands a request to the server through JS interop and reads the response back, +either as a string (`JsonRpcProcessor.ProcessSync`) or as UTF-8 bytes written straight into WebAssembly +memory (`JsonRpcProcessor.Process`). Both paths are measured below. ``` dotnet run --project samples/WasmHost @@ -18,9 +19,10 @@ The relevant pieces: - [wwwroot/index.html](wwwroot/index.html): starts the runtime with `Blazor.start()`, calls `DotNet.invokeMethod('WasmHost', 'Process', json)`, and contains the benchmark below. -Only the core package is referenced. It targets `net8.0`/`net10.0` (and `netstandard2.0`/`2.1`), has no -JSON library dependency, and does not use reflection emit, so it runs under the WebAssembly interpreter and -under AOT (`true` with the `wasm-tools` workload). +The sample targets `net10.0` and needs the .NET 10 SDK. It references only the core project, which also +produces `net8.0`, `netstandard2.1` and `netstandard2.0` assets, has no JSON library dependency, and does not +use reflection emit, so it runs under the WebAssembly interpreter and under AOT +(`true`). The `wasm-tools` workload is needed only for the AOT publish. ## Benchmark: JSON-RPC vs plain Blazor interop @@ -41,7 +43,9 @@ reach .NET here, and reports calls per second, microseconds per call, and RPCs p Measured on an 8-core desktop in Chrome 152 with .NET 10, 20,000 RPCs per row, once under the interpreter (`dotnet run`, no AOT) and once AOT-compiled (`dotnet publish -c Release` with the `wasm-tools` workload; the -better of two runs per row): +better of two runs per row). These are illustrative observations from one machine, not confidence intervals: +the better of two runs favours the faster observation, and a future update should report a median and range +over a fixed number of runs. | Path | interpreter, µs per call | RPC/s | AOT, µs per call | RPC/s | |---|---:|---:|---:|---:| @@ -68,9 +72,12 @@ What the numbers say: numbers costs about 64 µs, because the JSON marshalling of the argument array and result that the runtime does runs in the interpreter. Sending a whole JSON-RPC document through `[JSExport]` (58 µs) is cheaper than that. -- **Bytes beat strings.** The UTF-8 buffer path (53 µs) is faster than the string `[JSExport]` (58 µs) and - faster than the string request in a pure .NET loop (57 µs): with no string marshalled and no UTF-16 to UTF-8 - transcoding, the interop is gone and what remains is the parse, dispatch and response write themselves. +- **Bytes avoid the string marshalling.** Under the interpreter the UTF-8 buffer path (53 µs) is faster than + the string `[JSExport]` (58 µs) and than the string request in a pure .NET loop (57 µs): with no string + marshalled and no UTF-16 to UTF-8 transcoding, what remains is the parse, dispatch and response write + themselves. Under AOT the two single-request rows are within noise of each other (6.6 µs for the string, + 7.0 µs for the bytes), so this table does not show a universal advantage for bytes; the batch rows favour + bytes in both columns. - **One entry point, batched, beats one interop call per operation.** A batch of 100 over the byte path gets to 27,000 RPC/s (37 µs per request), well above the single-call `[JSInvokable]` rate for a bare add. If the page has many calls to make at once, batch them. @@ -84,8 +91,9 @@ What the numbers say: batch of 100 over bytes reaches 210,000 RPC/s) and the `[JSInvokable]` rows 3 to 4×, while the typed `[JSExport]` add, which had almost no interpreted code to begin with, stays at 0.3 µs. Under AOT the interop costs about 1 µs of the 7 (compare the byte path with the .NET loop); the rest is the server itself in - WebAssembly. The same request takes about 225 ns on the .NET 10 JIT (see the top-level README), so AOT - WebAssembly is still some 25× off native, down from 250× interpreted. + WebAssembly. On the .NET 10 JIT the top-level README's one-thread run measures about 217 ns per request for + a similar five-request mix, a different harness and workload, so AOT WebAssembly is roughly 30× off native + and the interpreter roughly 240×. How to get the most out of it, in order of payoff: @@ -95,7 +103,8 @@ How to get the most out of it, in order of payoff: with any static server (`python -m http.server --directory ` will do) and the page reports "AOT-compiled" next to "ready". The typed add stub barely changes; the JSON-RPC rows, which are .NET code, get 7 to 9× faster, as the AOT columns above show. -2. **Enter through `[JSExport]` with UTF-8 buffers, not `invokeMethod`.** Same document, 3× faster, no - loss of generality: this is `ProcessBytes` above. +2. **Enter through `[JSExport]`, not `invokeMethod`.** Same document, 3× faster under the interpreter and + 4× under AOT, no loss of generality. Use the UTF-8 buffer form (`ProcessBytes` above) when the page already + has bytes or wants to batch; for one string request the two `[JSExport]` forms cost about the same. 3. **Batch.** Per-document setup is amortised. 4. **Typed `[JSExport]` stubs for the two or three hottest methods**, JSON-RPC for everything else.