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
212 changes: 212 additions & 0 deletions docs/engineering/decisions/0016-pure-go-helpers-from-bounded-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# ADR 0016: Callable Pure Go Helpers From the Bounded Client

Date: 2026-06-21

Status: Accepted

## Context

The escape from the bounded `client {}` language (ADR 0008) is binary today:
either an expression fits the bounded subset, or the whole component is rewritten
as a hand-authored Go WASM island (ADR 0003 / ADR 0004). There is no middle
rung, so any logic the bounded language cannot express — non-trivial list
filtering/sorting, multi-field derivations, domain calculations — forces a
disproportionate jump to a full island the author owns end to end.

The bounded language is intentionally small and must stay that way (ADR 0008).
Widening it to cover every transform would erode the "bounded / Go-owned /
deterministic" principle and grow the surface the compiler must parse,
type-check, and explain. We want a ladder, not a cliff:

`bounded` → `bounded + rich expressions (#501/#504)` → `bounded + pure Go helpers`
→ `full WASM island`.

The hard constraint is ADR-level: **#384 — single-source client semantics.**
Whatever executes the helper must not reintroduce a second, drifting
implementation of any behavior. There must remain exactly one source of truth.

This ADR decides the three open questions from issue #519: the helper execution
model, purity enforcement, and type bridging.

## Decision

Add a `compute`/`computed`-level call to **pure, imported Go functions** from the
bounded client. The reactive shell stays compiler-owned and bounded; only the
helper body is author Go.

```gwdk
client {
// ui.FilterRows is an ordinary, pure Go function in a sibling/imported package.
computed Visible []Row { return ui.FilterRows(Rows, Query) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Specify a parseable imported-helper call form

The decided syntax uses a package-qualified call, but the bounded client expression grammar currently only permits calls when the callee is a bare identifier (internal/clientlang/expr_parser.go rejects member calls with only helper names can be called). If this ADR is the implementation contract, imported helpers have no parseable/resolvable form, so the documented example would be rejected unless an undocumented syntax change lands; define the qualified-call syntax and resolution here.

Useful? React with 👍 / 👎.

}
```

### 1. Execution model: compile the helper to WASM from its Go source

The pure Go helper is **compiled to WebAssembly from the same Go source the
server uses** and invoked through the existing component WASM island ABI (ADR
0004). It is never transpiled or re-expressed in JavaScript.
Comment on lines +46 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Define how helpers avoid unrelated server files

Because Go compiles all non-tag-excluded files in a package for GOOS=js, compiling a helper from the same package the server uses fails whenever that package also has unrelated server-only files or imports, even if the helper call graph itself is pure. That makes the advertised ordinary sibling/imported-package path unusable for common app packages; require helper packages to be browser-buildable or specify a generated shim/extraction step that only includes the helper dependency set.

Useful? React with 👍 / 👎.


This is the only model that satisfies #384 by construction: the helper has one
implementation (the Go source), and WASM is merely its compiled form. A Go→JS
transpiler (rejected below) would be a second implementation of Go semantics and
is exactly the divergence #384 forbids.

Concretely:

- A component that calls a pure Go helper resolves to the **WASM island lane**.
Its bounded reactive shell is still generated by the compiler from the bounded
client language (the author does not hand-write the island); the pure helpers
are linked into the same module and called by the generated shell.
- The shell ↔ helper boundary reuses ADR 0004's JSON bootstrap/patch ABI and the
single expression/runtime spec (the `RuntimeExpressionSpec` that already drives
Go/JS parity for builtins). The bounded shell semantics keep their single
source; the helper keeps its single source. Neither is duplicated.
- This is implemented in phases (see Follow-Up) so the first slice is a small,
reviewable delta on top of the existing WASM ABI rather than a new runtime.

Two surface details this ADR commits to so implementers do not have to
re-decide them:

- **Qualified-call syntax.** The bounded client grammar currently only parses a
call whose callee is a bare identifier (a component-local helper). A
package-qualified helper call such as `ui.FilterRows(...)` requires extending
the call grammar to accept a qualified callee `alias.Name`, resolved through
the page/component's declared `import` alias to a Go package symbol — the same
alias resolution `build {}` already uses for imported build-data functions.
Until that grammar extension lands, the example above does not parse; defining
it is part of the implementing change.
- **Package build isolation.** Go compiles every non-build-tag-excluded file in a
package for `GOOS=js`, so pointing a helper at a package that also holds
server-only files/imports would fail the WASM build even when the helper's own
call graph is pure. The helper module is therefore built from an extracted
dependency set — only the helper and its transitively pure dependencies — not by
compiling the whole sibling package; alternatively the author keeps helpers in a
browser-buildable package. The compiler owns the extraction so "an ordinary
sibling/imported function" stays usable for common app packages.

### 2. Purity enforcement is mandatory and static

A function callable from the client must be **pure and deterministic**. The
compiler runs a purity analysis on the helper's call graph and rejects, with
diagnostics, any function that:

- performs I/O (network, filesystem, `os`, `syscall`, logging);
- reads or writes package-level mutable state, or captures mutable globals;
- starts goroutines, uses channels, `sync`, or other concurrency;
- uses non-determinism (`time.Now`, `math/rand`, map-iteration-order-dependent
output, `unsafe`, pointers escaping into observable identity);
- **mutates a bridged input.** A `computed` helper must not write through its
parameters (or values reachable from them); evaluating a derived value may not
change its inputs or depend on aliasing. So an in-place mutator like
`sort.Slice(rows, …)` on the `Rows` argument is rejected even though `sort` is
otherwise pure;
- calls another function that is not itself pure.

Allowed: parameters (read-only), locals, pure arithmetic/string/slice/struct/map
value construction, and calls to other functions proven pure. The curated pure
standard-library allowlist (`strings`, `math`, `strconv`, the non-mutating parts
of `sort` such as `sort.SliceStable` on a locally-allocated copy, etc.) admits a
package only for its non-mutating, deterministic surface — a package is not
blanket-trusted, and its in-place mutators are allowed only on compiler-proven
local copies. Purity is a transitive property checked over the reachable call
graph; the result is cached per function. A helper that cannot be proven pure is
a compile error that names the offending call site, not a silent downgrade.

### 3. Type bridging reuses the bounded-language value model

Helper parameters and results must be **bridgeable types**: the same JSON-encoded
value model the bounded client already uses for `state`, `props`, and the island
ABI — scalars (`string`, `bool`, integer/float numerics), structs of bridgeable
fields, and slices/maps of bridgeable values. The compiler:
Comment on lines +118 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Constrain wide integers across the JSON bridge

Allowing all integer numerics through the JSON value model loses exactness for int64/uint64 values above JavaScript's safe-integer range, so a pure Go/WASM helper can return a correct ID or counter and the bounded client can still observe a rounded value. To preserve the single-source semantics promised here, constrain bridgeable integer ranges or encode wide integers explicitly with diagnostics.

Useful? React with 👍 / 👎.


- validates each helper argument's bounded-client type against the Go parameter
type, and the Go result type against the `computed` declared type;
- generates the marshal/unmarshal glue at the ABI boundary from the resolved Go
types (reusing the typed-result/struct-field machinery already used for SSR
load results and contracts);
Comment on lines +125 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve result field metadata for computed arrays

The bridge contract only calls out type validation and marshal/unmarshal glue, but the motivating []Row computed result also needs entries such as Visible[].Name in the client symbol table. Existing validation gives a computed only its normalized top-level type, so a filtered/sorted row helper could validate as an array while every row field used in g:for, g:key, or interpolations remains unknown; require resolved result-field metadata to be exposed with the computed value.

Useful? React with 👍 / 👎.

- **exposes resolved result-field metadata with the computed value.** A
`computed Visible []Row` must publish `Visible[].Name` (and the rest of `Row`'s
bridgeable fields) into the client symbol table, not just the top-level array
type — otherwise `g:for`, `g:key`, and row interpolations over the helper
result would see unknown fields. The same struct-field resolution that backs
typed SSR load results provides this metadata;
- **constrains wide integers.** `int64`/`uint64` values outside the IEEE-754
safe-integer range (±(2^53−1)) cannot survive the JSON/number bridge exactly,
which would let a correct Go/WASM helper return a value the bounded client
observes rounded — breaking single-source semantics. Bridgeable integers are
therefore range-checked (or must be encoded as strings) with a diagnostic when a
signature can carry out-of-range values. This matches the safe-integer bound the
formatting/date builtins already enforce;
- rejects unbridgeable signatures (channels, funcs, interfaces without a
bridgeable concrete contract, `unsafe.Pointer`) with a diagnostic.

No new value kinds are introduced; the bridge is the existing JSON contract.

## Consequences

### Positive

- Turns the bounded→WASM cliff into a ladder: authors reach for a pure Go helper
for the one transform that doesn't fit, instead of rewriting the screen.
- Single source of truth is preserved by construction (#384): the helper is
compiled, never re-implemented.
- Reuses existing infrastructure: the WASM island ABI (0004), the expression
runtime spec, and the typed struct-field/result machinery — minimal new
surface.
- Keeps the bounded language small (0008): pressure to add every transform as a
builtin is relieved by "extract a pure Go helper."

### Negative

- A component that uses helpers pays the WASM island cost (module size, load) it
would not pay as a pure-JS island. The ladder makes this an explicit author
choice, but it is a real cost.
- Purity analysis is non-trivial and must be conservative (false negatives that
reject a pure function are acceptable; false positives that admit an impure one
are not).
- Determinism across Go/WASM is inherited from the Go toolchain, but
floating-point and map-ordering pitfalls still need test coverage.

### Neutral

- Default JS islands are unchanged; nothing here alters ADR 0003's default.
- This is the chosen direction; the implementation is phased and not yet built
(see Follow-Up). The bounded language and existing islands behave as before
until a phase lands.

## Alternatives Considered

- **Transpile the pure Go helper to JavaScript.** Rejected: a Go→JS compiler is a
second implementation of Go semantics and is precisely the divergence #384
exists to prevent. Even a "subset" transpiler drifts.
- **Widen the bounded client language to cover the transforms.** Rejected: it
grows the surface 0008 deliberately bounds, and never covers the long tail;
authors still hit a wall, just later.
- **Force a full hand-authored WASM island (status quo).** Rejected: that is the
cliff this ADR removes; it makes the author own the reactive shell, props,
events, and lifecycle for what should be a one-function escape.
- **A JS island that calls a small WASM "helper sidecar."** Considered as a
size-optimization phase (keep the shell in JS, ship only the helper as WASM).
Deferred behind the single-module model because two runtimes per page is more
moving parts; revisited in Follow-Up if WASM shell size proves prohibitive.

## Follow-Up

- Depends on **#384** (single-source client semantics) landing first, so the
bounded shell has one IR/evaluator before helpers cross the boundary.
- **Phase 1:** allow pure Go helper calls only inside components already in the
WASM lane, with purity validation, type bridging, and generated glue. Smallest
reviewable delta; no lane change. Note that ADR 0004's component-level `wasm`
declaration today points at a *hand-authored* browser Go package that owns the
`GOWDKMount/Handle/Destroy` exports. Helper support is a **distinct
generated-helper path**: the compiler still owns the reactive-shell exports and
links the pure helpers as ordinary called functions (not lifecycle exports), so
there is one lifecycle owner. Phase 1 therefore targets compiler-generated WASM
components, not the hand-authored-island export contract, which is unchanged.
- **Phase 2:** auto-promote a bounded JS component to the WASM lane when it calls
a helper, so authors do not manage the lane manually.
- **Phase 3:** evaluate the JS-shell + WASM-helper-sidecar option if WASM shell
size is a problem in practice.
- Each phase ships with purity/bridging diagnostics, a native example, and
generated-output tests, per the #519 checklist.
7 changes: 7 additions & 0 deletions docs/engineering/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ Recommended naming:
query-owned elements as the first realtime reactivity source contract.
- `0013-built-in-tracing-observability.md`: accepted dependency-free
`runtime/trace` primitives before generated app auto-instrumentation.
- `0014-addon-runtime-config-split.md`: accepted split between addon config
packages and request-time runtime helper packages.
- `0015-generated-binary-lifecycle-services.md`: accepted generated binary
lifecycle service contracts.
- `0016-pure-go-helpers-from-bounded-client.md`: accepted direction to call pure
Go helpers from the bounded client by compiling them to WASM (single-source
semantics, purity validation, JSON type bridging).