Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
11 changes: 6 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions crates/webui-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
) -> napi::Result<Buffer> {
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.
Expand Down Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 9 additions & 9 deletions docs/guide/integrations/node.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions examples/integration/node-addon-bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions examples/integration/node-addon-bench/bench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -595,7 +597,7 @@ async function main() {
stateJson,
expected,
inputBytes: Buffer.byteLength(stateJson),
outputBytes: Buffer.byteLength(expected),
outputBytes: expectedBuffer.length,
chunkCount: chunks.length,
};
});
Expand Down
2 changes: 1 addition & 1 deletion packages/webui-test-support/src/fixture-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions packages/webui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/webui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 17 additions & 12 deletions packages/webui/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,39 +115,44 @@ 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('<p>a</p>'));
assert.ok(html.includes('<p>b</p>'));
});

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('<footer>Visible</footer>'));
});

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('<footer>'));
});

test('reuses one protocol for object and JSON string state', () => {
const result = build({ appDir });
const protocol = new Protocol(result.protocol);
const objectHtml = protocol.render({ name: 'Object', items: [], show: false });
const jsonHtml = protocol.render(
JSON.stringify({ name: 'JSON', items: [], show: false }),
);
const objectHtml = protocol
.render({ name: 'Object', items: [], show: false })
.toString('utf8');
const jsonHtml = protocol
.render(JSON.stringify({ name: 'JSON', items: [], show: false }))
.toString('utf8');
assert.ok(objectHtml.includes('Hello, Object!'));
assert.ok(jsonHtml.includes('Hello, JSON!'));
});
Expand All @@ -156,17 +161,17 @@ describe('render', () => {
const result = build({ appDir });
const protocol = new Protocol(result.protocol);
const state = { name: 'Cache', items: [], show: true };
const initialHtml = protocol.render(state);
const initialHtml = protocol.render(state).toString('utf8');
assert.ok(initialHtml.includes('<footer>Visible</footer>'));

const offset = result.protocol.indexOf('Visible');
assert.ok(offset >= 0);
result.protocol.write('Altered', offset, 'utf8');

const existingHtml = protocol.render(state);
const existingHtml = protocol.render(state).toString('utf8');
assert.ok(existingHtml.includes('<footer>Visible</footer>'));

const updatedHtml = new Protocol(result.protocol).render(state);
const updatedHtml = new Protocol(result.protocol).render(state).toString('utf8');
assert.ok(updatedHtml.includes('<footer>Altered</footer>'));
});

Expand Down
Loading