From 476d40f6fa2268ba477b67f24f6c123ffedbd99d Mon Sep 17 00:00:00 2001 From: Bang Lee Date: Mon, 20 Jul 2026 19:26:46 -0700 Subject: [PATCH] feat: return UTF-8 buffers from Node render Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- BENCHMARKS.md | 6 ++-- DESIGN.md | 11 +++---- crates/webui-node/src/lib.rs | 14 +++++---- docs/ai.md | 3 +- docs/guide/integrations/node.md | 18 ++++++------ .../integration/node-addon-bench/README.md | 6 ++-- .../integration/node-addon-bench/bench.mjs | 12 ++++---- .../webui-test-support/src/fixture-render.ts | 2 +- packages/webui/README.md | 16 ++++++---- packages/webui/src/index.ts | 6 ++-- packages/webui/test/integration.test.ts | 29 +++++++++++-------- 11 files changed, 69 insertions(+), 54 deletions(-) diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 9f79a7a7..d79c1b51 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -142,7 +142,7 @@ V8/N-API string and callback crossings: - **Protocol construction** — Node `Buffer` to protobuf decode/index after the addon is loaded - **JSON-string render** — N-API conversion, JSON parse, Rust render, - and the returned JavaScript string + and the returned UTF-8 Node `Buffer` - **Object render** — the same path plus public-wrapper `JSON.stringify` - **Streaming first callback** — state conversion and JSON parse, @@ -152,8 +152,8 @@ V8/N-API string and callback crossings: It uses the same Contact Book fixture and 10/100/1000 scales as the Rust end-to-end benchmark, rendering `/contacts` so output grows with the workload. The first-callback metric is in-process; it is not HTTP -TTFB. The runner verifies buffered, object-state, and -streamed output are byte-identical before collecting samples. +TTFB. The runner verifies JSON-string, object-state, and streamed output are +byte-identical before collecting samples. ## Recommended PR workflow diff --git a/DESIGN.md b/DESIGN.md index 1b7458ff..b5fe16da 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2919,11 +2919,12 @@ The `@microsoft/webui` npm package follows the esbuild single-package model: - `Protocol` is the only runtime rendering API; construction decodes and indexes a protocol `Buffer` once and binds the selected plugin - callers own the lifecycle explicitly, so the package has no hidden - `WeakMap`, no protocol-sized mutation snapshot, and no byte-per-call render - functions -- `Protocol.render()` returns the buffered-string result; - `Protocol.renderStream()` batches callbacks with a 16 KiB target instead of - crossing into JavaScript for every internal handler fragment + `WeakMap`, no protocol-sized mutation snapshot, and no render functions that + accept protocol bytes on every call +- `Protocol.render()` returns the rendered UTF-8 bytes as the canonical Node + `Buffer` result; callers explicitly decode it when they need a JavaScript + string; `Protocol.renderStream()` batches callbacks with a 16 KiB target + instead of crossing into JavaScript for every internal handler fragment - render currently requires the native addon; no WASM render fallback is wired ### .NET / NuGet Distribution diff --git a/crates/webui-node/src/lib.rs b/crates/webui-node/src/lib.rs index 9c80f14b..a28922c8 100644 --- a/crates/webui-node/src/lib.rs +++ b/crates/webui-node/src/lib.rs @@ -255,17 +255,19 @@ impl Protocol { Ok(Self { inner, handler }) } - /// Render from an existing JSON string. + /// Render from an existing JSON string into a UTF-8 Node.js buffer. #[napi] pub fn render( &self, state_json: String, entry: String, request_path: String, - ) -> napi::Result { + ) -> napi::Result { let state = parse_state_json(&state_json)?; let options = RenderOptions::new(&entry, &request_path); - render_to_string(&self.handler, &self.inner, &state, &options) + let html = render_to_string(&self.handler, &self.inner, &state, &options)?; + // napi-rs can expose the existing Vec as an external Buffer on Node. + Ok(Buffer::from(html.into_bytes())) } /// Stream an existing JSON string in bounded chunks. @@ -538,14 +540,14 @@ mod tests { .expect("first render should succeed"); let second = protocol .render( - r#"{"name":"Second"}"#.to_string(), + r#"{"name":"世界"}"#.to_string(), "index.html".to_string(), "/".to_string(), ) .expect("second render should succeed"); - assert_eq!(first, "Hello, First!"); - assert_eq!(second, "Hello, Second!"); + assert_eq!(first.as_ref(), b"Hello, First!"); + assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes()); } #[test] diff --git a/docs/ai.md b/docs/ai.md index 98912f5c..774c7567 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1204,7 +1204,8 @@ rebuilds when the adapter atomically replaces the manifest. WebUI renders from **any** backend. The server loads `protocol.bin` into one `Protocol`, then renders with JSON state per request. Projection manifests are consumed only while producing `protocol.bin`; runtime rendering -APIs are unchanged. +does not load projection tooling. In Node, `Protocol.render()` returns a UTF-8 +`Buffer`; call `.toString('utf8')` only when string operations are required. ### Rust diff --git a/docs/guide/integrations/node.md b/docs/guide/integrations/node.md index 43b608c1..3b3358f7 100644 --- a/docs/guide/integrations/node.md +++ b/docs/guide/integrations/node.md @@ -1,9 +1,8 @@ # WebUI Native Node Module Handler The `@microsoft/webui` npm package provides high-performance server-side -rendering for Node.js, Bun, and Deno. It uses a native addon with direct -`Buffer` access, a buffered string path for normal rendering, and batched -callbacks for streaming responses. +rendering for Node.js, Bun, and Deno. It uses a native addon with a canonical +UTF-8 `Buffer` path plus batched callbacks for streaming responses. ## Installation @@ -98,7 +97,7 @@ Deno.serve({ port: 3000 }, (req) => { |----------|-------------| | `build(options)` | Build templates into a protocol. Returns `{ protocol, cssFiles, componentAssetFiles, warnings, stats }` | | `new Protocol(protocol, options?)` | Decode and index protocol bytes once and bind the selected plugin | -| `protocol.render(state, options?)` | Render with route matching through the native buffered-string path | +| `protocol.render(state, options?)` | Render into a UTF-8 `Buffer` for direct HTTP writes | | `protocol.renderStream(state, onChunk, options?)` | Render with callbacks coalesced around a 16 KiB target before crossing into JavaScript | | `protocol.renderPartial(state, entry, requestPath, inventory)` | Produce a complete partial-navigation JSON response | | `protocol.renderComponentTemplates(tags, inventory)` | Return on-demand template payloads | @@ -142,12 +141,13 @@ const server = createServer((req, res) => { `Protocol` owns the decoded native state, deterministic index, and template metadata cache. The source `Buffer` can be released or reused after construction. The package has no hidden `WeakMap`, protocol-sized mutation -snapshot, or byte-per-request render path. +snapshot, or render path that accepts protocol bytes on every request. -Use `protocol.render()` when the complete HTML string is needed. Use -`protocol.renderStream()` when the HTTP integration can make progress from -callbacks; callbacks are batched rather than invoked for every internal -handler write. +`protocol.render()` returns a UTF-8 `Buffer` so the native allocation can be +passed directly to `response.end()`. Call `.toString('utf8')` only when +JavaScript string operations are required. Use `protocol.renderStream()` when +the HTTP integration can make progress from callbacks; callbacks are batched +rather than invoked for every internal handler write. ### BuildOptions diff --git a/examples/integration/node-addon-bench/README.md b/examples/integration/node-addon-bench/README.md index d7b16a5f..9c93ea54 100644 --- a/examples/integration/node-addon-bench/README.md +++ b/examples/integration/node-addon-bench/README.md @@ -61,7 +61,7 @@ directly. | Case | Boundary included | What it tells you | |---|---|---| | `protocol/new` | Node `Buffer` -> N-API -> protobuf decode/index | `Protocol` construction after the addon is loaded | -| `render/json-string/N` | JS string -> N-API -> JSON parse -> Rust render -> JS string | native request cost when state is already serialized | +| `render/json-string/N` | JS string -> N-API -> JSON parse -> Rust render -> external Node `Buffer` | canonical buffered response when state is already serialized | | `render/object/N` | `JSON.stringify` plus the JSON-string path | public-package cost for the common object-state API | | `render-stream/...`, `first-callback` | JS string -> N-API -> JSON parse -> render to first 16 KiB -> JS callback | latency until JavaScript receives the first chunk | | `render-stream/...`, `total` | the same input path plus all chunks and callbacks | complete streaming callback-path cost | @@ -95,7 +95,7 @@ runner. - Collects at least 20 samples per row and targets 750 ms of measurement time. - Reports workload sizes/chunk counts plus min, P50, P95, P99, and mean-derived ops/s. -- Verifies object-state and JSON-string renders are byte-identical. +- Verifies object and JSON-string inputs produce byte-identical buffers. - Verifies concatenated streaming chunks exactly equal buffered output. - Rejects empty protocols, missing chunks, malformed baselines, unsafe baseline names, and accidental debug measurements. @@ -109,7 +109,7 @@ pnpm --filter node-addon-bench bench:json ## Why a separate integration package? This benchmark cannot be a Criterion target: calling the Rust functions from a -Rust harness would bypass V8, JavaScript string conversion, N-API, and callback +Rust harness would bypass V8, Buffer ownership transfer, N-API, and callback crossings. Keeping it next to `streaming-browser-bench` makes the external runtime requirement explicit while letting pnpm manage the public package under test. diff --git a/examples/integration/node-addon-bench/bench.mjs b/examples/integration/node-addon-bench/bench.mjs index 1712cd7c..cc237dbd 100644 --- a/examples/integration/node-addon-bench/bench.mjs +++ b/examples/integration/node-addon-bench/bench.mjs @@ -570,12 +570,14 @@ async function main() { const fixtures = CONTACT_COUNTS.map((contactCount) => { const state = buildState(seedState, contactCount); const stateJson = JSON.stringify(state); - const expected = protocol.render(stateJson, RENDER_OPTIONS); - assert.equal( - protocol.render(state, RENDER_OPTIONS), - expected, + const expectedBuffer = protocol.render(stateJson, RENDER_OPTIONS); + assert.ok(Buffer.isBuffer(expectedBuffer), "render did not return a Node.js buffer"); + const objectBuffer = protocol.render(state, RENDER_OPTIONS); + assert.ok( + objectBuffer.equals(expectedBuffer), `object and JSON-string render differ at ${contactCount} contacts`, ); + const expected = expectedBuffer.toString("utf8"); const chunks = []; protocol.renderStream( stateJson, @@ -595,7 +597,7 @@ async function main() { stateJson, expected, inputBytes: Buffer.byteLength(stateJson), - outputBytes: Buffer.byteLength(expected), + outputBytes: expectedBuffer.length, chunkCount: chunks.length, }; }); diff --git a/packages/webui-test-support/src/fixture-render.ts b/packages/webui-test-support/src/fixture-render.ts index 41af3024..442b490a 100644 --- a/packages/webui-test-support/src/fixture-render.ts +++ b/packages/webui-test-support/src/fixture-render.ts @@ -104,7 +104,7 @@ function renderOne( const renderEntry = hasAuthoredEntry ? 'src/index.html' : 'index.html'; const protocol = new Protocol(result.protocol, { plugin: 'webui' }); - let html = protocol.render(state, { entry: renderEntry }); + let html = protocol.render(state, { entry: renderEntry }).toString('utf8'); { const scriptPath = hasAuthoredEntry diff --git a/packages/webui/README.md b/packages/webui/README.md index 030a3b5c..1eb2981e 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -23,7 +23,7 @@ const result = build({ appDir: "./src" }); // Decode and index once, then render repeatedly const protocol = new Protocol(result.protocol, { plugin: "webui" }); const html = protocol.render({ name: "World", items: ["a", "b"] }); -console.log(html); +console.log(html.toString("utf8")); ``` ## API @@ -121,17 +121,21 @@ const protocol = new Protocol(protocolBytes, { plugin: "webui" }); ``` `Protocol` owns its decoded native state. The package does not keep a hidden -`WeakMap`, copy the source `Buffer`, or expose byte-per-request render -functions. +`WeakMap`, copy the source `Buffer`, or expose render functions that accept +protocol bytes on every request. -### `protocol.render(state: object | string, options?: RenderOptions): string` +### `protocol.render(state: object | string, options?: RenderOptions): Buffer` -Renders state and returns the full HTML string. +Renders state into a UTF-8 Node.js `Buffer`, the canonical buffered result for +direct HTTP, file, or socket writes: ```js -const html = protocol.render({ title: "Hello", show: true }); +response.end(protocol.render({ title: "Hello" })); ``` +Call `.toString("utf8")` explicitly when JavaScript string operations are +required. + ### `protocol.renderStream(state, onChunk, options?): void` Renders with streaming output. Internal handler writes are coalesced around a diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 7ca71a25..67c71272 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -142,7 +142,7 @@ interface NativeAddon { } interface NativeProtocol { - render(stateJson: string, entry: string, requestPath: string): string; + render(stateJson: string, entry: string, requestPath: string): Buffer; renderStream( stateJson: string, entry: string, @@ -310,8 +310,8 @@ export class Protocol { this.#native = new NativeProtocol(protocolData, options?.plugin); } - /** Render a complete HTML response. */ - render(state: object | string, options?: RenderOptions): string { + /** Render a complete HTML response as a UTF-8 Node.js buffer. */ + render(state: object | string, options?: RenderOptions): Buffer { const stateJson = typeof state === "string" ? state : JSON.stringify(state); return this.#native.render( stateJson, diff --git a/packages/webui/test/integration.test.ts b/packages/webui/test/integration.test.ts index a9aae987..30d6a11c 100644 --- a/packages/webui/test/integration.test.ts +++ b/packages/webui/test/integration.test.ts @@ -115,14 +115,17 @@ describe('render', () => { test('substitutes signals', () => { const result = build({ appDir }); const protocol = new Protocol(result.protocol); - const html = protocol.render({ name: 'WebUI', items: ['a', 'b'], show: true }); - assert.ok(html.includes('Hello, WebUI!')); + const buffer = protocol.render({ name: 'WebUI', items: ['a', 'b'], show: true }); + assert.ok(Buffer.isBuffer(buffer)); + assert.ok(buffer.toString('utf8').includes('Hello, WebUI!')); }); test('expands for-loop', () => { const result = build({ appDir }); const protocol = new Protocol(result.protocol); - const html = protocol.render({ name: 'X', items: ['a', 'b'], show: false }); + const html = protocol + .render({ name: 'X', items: ['a', 'b'], show: false }) + .toString('utf8'); assert.ok(html.includes('

a

')); assert.ok(html.includes('

b

')); }); @@ -130,24 +133,26 @@ describe('render', () => { test('includes if-true block', () => { const result = build({ appDir }); const protocol = new Protocol(result.protocol); - const html = protocol.render({ name: 'X', items: [], show: true }); + const html = protocol.render({ name: 'X', items: [], show: true }).toString('utf8'); assert.ok(html.includes('
Visible
')); }); test('excludes if-false block', () => { const result = build({ appDir }); const protocol = new Protocol(result.protocol); - const html = protocol.render({ name: 'X', items: [], show: false }); + const html = protocol.render({ name: 'X', items: [], show: false }).toString('utf8'); assert.ok(!html.includes('