Skip to content

feat(theme): site-wide Node.js | Bun runtime switcher#65

Merged
intech merged 4 commits into
mainfrom
docs/runtime-switcher
Jul 21, 2026
Merged

feat(theme): site-wide Node.js | Bun runtime switcher#65
intech merged 4 commits into
mainfrom
docs/runtime-switcher

Conversation

@intech

@intech intech commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What

A site-wide Node.js | Bun runtime switcher: a control in the navigation bar (and in the mobile nav screen), plus a ::: runtime markdown container so a page can carry runtime-specific commands instead of splitting into parallel Node.js and Bun pages.

::: runtime
== node
```bash
pnpm add @connectum/core

== bun

bun add @connectum/core

:::


`::: runtime bun` renders a single-runtime block for a caveat that has no Node.js counterpart.

## How it works

- **Both variants are server-rendered** into the static HTML and only hidden with CSS, driven by `data-runtime` on `<html>`. Crawlers, the local search index and `llms.txt` therefore see both runtimes, and a reader without JavaScript gets a complete Node.js page.
- **No flash of the wrong runtime**: the stored choice is applied by an inline `<head>` script before first paint — the same technique VitePress uses to restore dark mode. Existing VitePress plugins for this (`vitepress-plugin-package-managers`, `vitepress-plugin-tabs`) restore in `onMounted` and flash by construction; the tabs plugin additionally drops the unselected variant from the static HTML.
- **No hydration mismatch by construction**: content is static HTML and every active state (tab highlight, current entry in the navbar control) is pure CSS from the attribute. Verified with zero Vue hydration warnings in dev across 6 pages × 2 runtimes.
- **Shareable**: `?runtime=bun` in a link wins over `localStorage`; a second browser tab follows through the `storage` event.
- **Build-time guards**: the build fails on a heading inside a block (it would duplicate the page outline and produce suffixed anchors such as `#install-1`), on a duplicate runtime section, and on an unknown runtime name.
- Below 960px the navigation bar has no room for another control (it already overflows at 768px without it) and VitePress hides the hamburger from 768px up, so in that band the per-block tabs are the way to switch.

Authoring rules are documented in the [documentation style guide](en/contributing/documentation-style.md#runtime-specific-content).

## Content changes

Runtime-specific bodies (headings stay shared, outside the blocks):

- **Quickstart** — prerequisites, project setup, install commands, `package.json` scripts, run commands, CLI and observability steps.
- **TypeScript patterns** — type-check and dev-loop commands; the erasable-syntax checklist is now split, since Bun transpiles TypeScript rather than stripping types (`enum`, `namespace` and parameter properties execute as written — verified locally).
- **Docker** — install layer, base image and runtime command (`oven/bun:1-slim`, `bun run src/index.ts`, `bunx buf generate`); the Alpine and image-size sections are relabelled as Node.js images rather than being hidden.
- **15 package pages** — `pnpm add` / `bun add` install blocks.
- **Testing snippets** (interceptors, auth context, health checks, `@connectum/testing`) — note the `bun:test` import; `node:assert` and the Connectum mock helpers work unchanged.

## Beyond the switcher: corrections to Bun claims

Rendering Bun instructions next to stale Bun limitations would have presented the old text as the project's current position, so the claims were re-verified against real runs before shipping the switcher. **These changes go beyond the original request and are the part worth reviewing closely.**

Test matrix: Bun 1.1.38, 1.2.0, 1.2.5, 1.2.6, 1.2.7, 1.2.8, 1.2.9, 1.2.10, 1.2.15, 1.2.21, 1.3.0, 1.3.13 × `@connectrpc/connect-node` 2.0.0 and 2.1.2, against `@connectum/core` 1.2.0 from npm; unary, server-streaming, bidi with full drain, and error codes carried in HTTP/2 trailers; 3 runs per scenario, plus a split harness (client on Bun, server on Node.js and vice versa).

| Documented claim | Result |
|---|---|
| `createGrpcTransport()` / `createConnectTransport({ httpVersion: '2' })` throw a `TypeError` on Bun | **Wrong symptom, stale version.** No `TypeError` in any combination. On Bun ≤ 1.2.5 the transport is built without error and the first RPC **hangs**. **Bun 1.2.6 rewrote the `node:http2` client**; from 1.2.6 on, unary, server-streaming and bidi all work and trailers arrive intact. |
| Streaming over `createConnectTransport()` fails because of Bun's `node:http` layer | **Not reproducible on any version.** Server-streaming and error trailers over HTTP/1.1 pass from Bun 1.1.38 onward. Bidi requires HTTP/2 on every runtime, Node.js included. |
| Workaround: `createTransport` + `createFetchClient(globalThis.fetch)` | **Removed.** It does not compile as printed (the option is `httpClient`, and `CommonTransportOptions` needs more fields), `createTransport` is marked internal by ConnectRPC, and on Bun 1.1.x it silently drops the request body. |
| Transport matrix: native gRPC is "effectively a Node story", Bun ❌ | **Corrected.** That row is about `Bun.serve`; Connectum's `createServer()` uses `node:http2`, which serves native gRPC on Bun — including plaintext h2c — on every version tested. |
| `@connectum/cli` → Bun "Yes" | **Partial.** `test:bun` is a SKIP ("Node-specific gRPC transport deps") and no CI job runs the CLI under Bun. Run it with `npx`. |
| `@connectum/testing` → Bun "Partial" (unexplained) | Explained: only the `@connectum/testing/parity` subpath imports `node:test`. |
| `createMockNext()` "returns a `mock.fn()` spy from `node:test`" | **Corrected** — it returns the portable spy from `createMockFn()`, which does not import `node:test`. |

The compatibility matrix now reads **Bun >= 1.2.6**, and the known-issues table carries one version-qualified row instead of three open ones.

Not verified, stated as such: the `TypeError` symptom could not be reproduced on any tested combination (it may predate `@connectrpc/connect-node` 2.x); Bun < 1.1.38 was not tested; TLS/ALPN was not tested.

## Validation

Playwright against the **built** site (`docs:build` + `docs:preview`), plus a dev pass for hydration warnings:

- default Node.js; switching from the navbar control and from a block tab; selection persists across SPA navigation and reload
- first-frame probe after reload with Bun stored: `data-runtime=bun` already set at `firstFrame` and `readyState: interactive` — no flash
- `?runtime=node` overrides stored `bun` and is persisted
- JavaScript disabled: Node.js content visible, Bun content hidden
- mobile (390×844): navbar control hidden, segmented control present in the nav screen and working; navbar widths 375–1440 checked for overflow
- keyboard: Enter on a block tab and on the navbar control, Tab into the menu, Escape to close
- print emulation: all variants shown and labelled, controls hidden
- local search: `bun add` → 37 hits, `bunx` → 23, `oven` → the Docker runtime command, i.e. Bun content is indexed
- zero console errors and zero Vue hydration warnings (6 pages × 2 runtimes in dev)
- guards exercised: heading inside a block, duplicate section and unknown runtime each fail the build with a file:line message

## Follow-ups

- `en/guide/scaffolding.md` is still in flight in #64 and is not touched here; it needs the same treatment after that PR lands, including the `connectum init --package-manager` values (`pnpm`, `npm` — no `bun`).
- Both variants land in the local search index, so a search hit can point at a section whose visible variant is the other runtime. The per-block tabs make the alternative visible right there; no auto-switching is attempted.

intech added 2 commits July 20, 2026 02:48
Adds a runtime switcher to the navigation bar (desktop and mobile nav screen)
and a `::: runtime` markdown container, so pages can carry runtime-specific
commands without splitting into separate Node.js and Bun pages.

Mechanism:

- Every variant is server-rendered into the static HTML and only hidden with
  CSS, driven by `data-runtime` on `<html>`. Crawlers, the local search index
  and `llms.txt` therefore see both runtimes, and readers without JavaScript
  get the Node.js page.
- The stored choice is applied by an inline `<head>` script before first paint
  (the technique VitePress uses for dark mode), so there is no flash of the
  wrong runtime and no hydration mismatch: server and client markup are
  identical, and every active state is pure CSS.
- `?runtime=bun` shares the choice through a link and wins over localStorage;
  a second browser tab follows through the `storage` event.
- The build fails on a heading inside a block, on a duplicate runtime section
  and on an unknown runtime name -- headings inside a block would duplicate the
  page outline and produce suffixed anchors.

Content:

- Quickstart, TypeScript patterns, Docker and the 15 package pages now show
  runtime-specific prerequisites, install commands, scripts and run commands.
- Testing snippets note the `bun:test` import; `node:assert` and the Connectum
  mock helpers work unchanged under Bun.

Corrections found while verifying Bun content against real runs (Bun 1.1.38,
1.2.0, 1.2.5, 1.2.6, 1.2.21, 1.3.0, 1.3.13 x @connectrpc/connect-node 2.0.0 and
2.1.2, @connectum/core 1.2.0):

- HTTP/2 client transports do not throw a `TypeError` on Bun; on Bun <= 1.2.5
  the first RPC hangs. Bun 1.2.6 fixed the `node:http2` client, and unary,
  server-streaming and bidi calls all work from that version on.
- Streaming over `createConnectTransport()` on HTTP/1.1 works on every Bun
  version tested; the `node:http` claim was wrong.
- The documented `createTransport` + `createFetchClient(globalThis.fetch)`
  workaround is removed: it does not compile as printed, relies on an API
  ConnectRPC marks internal, and silently drops the request body on Bun 1.1.x.
- Connectum servers serve native gRPC on Bun -- `createServer()` uses
  `node:http2`, not `Bun.serve`; the transport matrix said otherwise.
- `@connectum/cli` is Partial on Bun (exercised on Node.js only);
  `@connectum/testing` is Partial only because of the `/parity` subpath.
- `createMockNext()` does not return a `node:test` spy.

Authoring rules for the new container are documented in the documentation style
guide.
The quickstart linked `#opentelemetry`, but the heading anchor is `#otel` --
VitePress dead-link checking only validates pages, not fragments. The navbar
note said CI coverage is Node.js only, which understates the Bun job that runs
`test:bun` on every pull request; only cli, otel and interceptors skip it.
@github-actions github-actions Bot added the type:feature New feature or enhancement request label Jul 19, 2026
intech added 2 commits July 20, 2026 03:02
The heading said `node:25-slim` while the Bun branch below it recommends
`oven/bun:1-slim`.
The default theme only offers `nav-bar-content-after`, so the control is
reordered with flex `order`; the search box gives up its `flex-grow` above
960px so the pair stays on the left instead of drifting towards the menu, and
the dropdown opens left-aligned to match.
@intech
intech merged commit 7307173 into main Jul 21, 2026
3 checks passed
@intech
intech deleted the docs/runtime-switcher branch July 21, 2026 04:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant