chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:49 +08:00
commit 505bac6b53
1470 changed files with 457757 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
---
name: automation
description: Automation and verification guide for running Native SDK apps. Use when the user asks to test a running app, inspect runtime state, list windows, wait for readiness, drive widgets, take deterministic screenshots, send bridge commands, debug why automation is not connected, create smoke tests, or verify a Native SDK example in a GUI-capable session.
---
# Automate Native SDK apps
Every Native SDK app embeds an automation server — native-rendered apps and WebView-shell apps alike. It works through file-based IPC in `.zig-cache/native-sdk-automation/` and is intended for smoke tests, CI checks with a GUI session, and quick runtime inspection: accessibility snapshots, widget driving through the real input paths, deterministic reference-renderer screenshots, readiness/state assertions, and bridge round-trips.
Automation is not browser DOM automation. It reports runtime/window/widget state, drives retained-canvas widgets, and can ask the runtime to reload or dispatch bridge requests. For DOM testing of the optional WebView path, use the frontend framework's tests or a browser automation tool against the dev server.
## What automation can verify
- An automation-enabled app started and published `ready=true`.
- The runtime loaded the expected app name, source kind, and window metadata.
- The main window exists and is focused/open.
- The JavaScript-to-Zig bridge can round-trip a request through `native automate bridge`.
- Builtin window/WebView commands work when exercised by a smoke test.
- Reload requests are accepted by the runtime.
- Real pixels of retained-canvas (`gpu_surface`) views: `native automate screenshot <view-label>` renders the view's current canvas frame through the deterministic CPU reference renderer and writes a PNG artifact. Two captures of an unchanged scene are byte-identical, so screenshots can back golden-image or "did the UI change" checks.
## What automation cannot verify
- Screenshots of WebView content. `screenshot` covers `gpu_surface` canvas views only; there is no DOM/WebView pixel capture.
- Arbitrary DOM queries and clicks.
- Browser network assertions.
## Prerequisites
Build/run an app with automation enabled. Generated examples usually expose `-Dautomation=true`:
```bash
zig build run -Dplatform=macos -Dautomation=true
```
Repository examples may have specialized steps:
```bash
zig build run-webview -Dplatform=macos -Dautomation=true
zig build test-webview-smoke -Dplatform=macos
```
The runner must pass an automation server into `RuntimeOptions`:
```zig
const server = native_sdk.automation.Server.init(io, ".zig-cache/native-sdk-automation", "My App");
var runtime = native_sdk.Runtime.init(.{
.platform = my_platform,
.automation = server,
});
```
Apps built without `-Dautomation=true` usually ignore automation files.
## Commands
```bash
native automate wait
native automate assert 'gpu_nonblank=true' 'role=button name="Reset"'
native automate assert --absent 'error event='
native automate list
native automate snapshot
native automate reload
native automate screenshot inbox-canvas
native automate screenshot inbox-canvas 2
native automate widget-action canvas 2 press
native automate widget-click canvas 3
native automate widget-hold canvas 3
native automate widget-context-press canvas 3
native automate widget-drag canvas 4 0.25 0.82
native automate widget-wheel canvas 5 18
native automate widget-key canvas tab
native automate widget-key canvas cmd+c
native automate profile on
native automate profile off
native automate bridge '{"id":"smoke","command":"native.ping","payload":{"source":"automation"}}'
```
If using the repository-built CLI:
```bash
zig-out/bin/native automate wait
zig-out/bin/native automate snapshot
```
## Snapshot assertions (`automate assert`)
Prefer `native automate assert` over `snapshot | grep` chains: it polls, so no sleeps, and its failure output carries the evidence (each missing pattern plus the snapshot tail).
```bash
native automate assert 'gpu_nonblank=true' 'role=button name="Reset"' 'count: 0'
native automate assert --timeout-ms 10000 '4 open'
native automate assert --absent 'error event=' 'dispatch_errors=[1-9]'
```
Semantics:
- Every argument is a regex that must match somewhere in `snapshot.txt`. The command polls (100ms interval) until all match, then exits 0.
- `--timeout-ms <n>` bounds the polling (default 30000). On timeout it prints `missing: <pattern>` for each unmatched pattern, the last 20 snapshot lines, and exits non-zero — CI-friendly, no wrapper script needed.
- `--absent` inverts the whole invocation: every pattern must NOT match (poll until gone). Mix presence and absence by running two invocations.
- Supported regex subset: literals, `.`, postfix `*` `+` `?`, line anchors `^`/`$`, classes `[a-z]`/`[^0-9]`, and `\d \w \s` (with uppercase negations). No groups or alternation — pass multiple patterns instead.
- Quote patterns in single quotes so the shell leaves `"`, `$`, and `\d` alone.
## Standard workflow
1. Start the app with automation enabled.
2. Run `native automate wait` to block until `snapshot.txt` contains `ready=true`.
3. Run `native automate assert '<pattern>' ...` for state checks, or `native automate snapshot` to eyeball app/window/source metadata.
4. Run `native automate list` to inspect window summaries.
5. Run `native automate bridge '...'` for bridge round-trip checks.
6. Use `native automate widget-action <view-label> <widget-id> <action> [value]` to exercise retained canvas widget actions. `set_text` routes through the SAME input path real typing uses (focus, select-all, then a text-input event), so a TEA app's `on_input` mirror receives the edits and model state stays consistent with the on-screen field — it is not a presentation-only write.
7. Use `native automate widget-click <view-label> <widget-id>` to exercise pointer-style retained widget routing. `widget-hold <view-label> <widget-id>` drives a press-and-hold through the same path — pointer down, the reserved hold timer fired, then the suppressed release — so `on_hold` Msgs are live-drivable (a target without `on_hold` degrades to the click a real long press is). `widget-context-press <view-label> <widget-id>` is the secondary click: it presents the widget's context menu, or dispatches `on_hold` immediately when the route declares none.
8. Use `native automate widget-drag <view-label> <widget-id> <start-x-ratio> <end-x-ratio> [start-y-ratio end-y-ratio]` for continuous pointer controls.
9. Use `native automate widget-wheel <view-label> <widget-id> <delta-y>` for retained widget scroll input. Wheel targets must be interactive/scrollable widgets — a plain layout column or text node is not a wheel target; aim at the scroll/list widget id from the snapshot. Failures land in the snapshot as named reasons: `error event=automation.widget_wheel name=WheelTargetUnknown|WheelTargetNotInteractive|WheelTargetHasEmptyBounds detail="<command args>"`.
10. Use `native automate widget-key <view-label> <key> [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS).
11. Use `native automate screenshot <view-label> [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-<view-label>.png` (the CLI prints the artifact path and waits for the file).
12. Use `native automate tray-action <item-id>` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`.
13. Use `native automate reload` to request a WebView reload.
14. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`<stage>_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window.
## Screenshots
`screenshot <view-label> [scale]` asks the runtime to rasterize the view's
current retained canvas frame through the deterministic CPU reference
renderer — the same pixel path the Linux software presentation uses — and
publish it as an uncompressed PNG at
`.zig-cache/native-sdk-automation/screenshot-<view-label>.png`. The file is
written atomically (temp file + rename), so its presence means the PNG is
complete.
Determinism semantics:
- Screenshots render at scale 1 by default regardless of the display's
backing scale, so an unchanged scene produces byte-identical PNGs from
capture to capture on the same machine. Pass an explicit scale (for
example `2`) for high-DPI pixel dimensions.
- Screenshots use the live retained scene, including live design tokens and
platform text measurement (CoreText on macOS): the layout matches what is
on screen. Glyphs are rasterized by the reference renderer from the
bundled faces, not by the platform's font rasterizer, so screenshots are a
deterministic layout/structure/color signal rather than a platform
font-rendering signal.
- Cross-machine byte-identity is only guaranteed where text metrics are
deterministic (the null platform's estimator). On platforms with a native
text measurement provider, text widths can differ between OS versions, so
compare screenshots taken on the same machine or assert on properties
(dimensions, changed/unchanged) rather than exact bytes across machines.
- OS-level captures (`screencapture -x` on macOS) are NOT a substitute: in a
shell without Screen Recording permission they exit 0 and silently return
wallpaper-only images with no app window in them. If you shell out for a
real-pixel capture, verify the image is not blank/wallpaper before trusting
it; `automate screenshot` plus a semantics snapshot is the reliable pair.
## Bridge smoke test pattern
The request must be JSON with an ID, command, and payload:
```bash
native automate bridge '{"id":"smoke","command":"native.ping","payload":{"source":"automation"}}'
```
Automation sends the request with origin `zero://inline`. The app's bridge policy must allow that origin or the call will reject with `permission_denied`. For packaged asset origins, app code often allows `zero://app`; for automation smoke tests, add `zero://inline` only when the test needs it.
Expected response shape depends on the handler. A typical `native.ping` handler returns:
```json
{"id":"smoke","ok":true,"result":{"message":"pong","count":1}}
```
If the command fails, inspect the bridge error code:
- `unknown_command`: no handler registered or wrong command name.
- `permission_denied`: origin or permission policy blocked it.
- `handler_failed`: Zig handler returned an error or invalid JSON.
- `payload_too_large`: request exceeded bridge limits.
## File protocol
The default directory is `.zig-cache/native-sdk-automation/`, resolved against the CLI's CURRENT WORKING DIRECTORY — run `native automate` from the app project's directory (where the app was launched). The dir is created by the running app, never by the CLI: a command sent from the wrong cwd fails loudly (`error: no automation dir at <abs path>`) instead of queueing into a dir no app reads, and every queued command prints the absolute dir it wrote to — check that line when a command seems to do nothing.
Files:
- `snapshot.txt`: app name, readiness, source kind, source size, window metadata, accessibility summary. The `ready=true` line also carries `protocol=<n>` (the CLI/app handshake: the CLI refuses snapshots — and command queues to a live app — whose protocol version is not its own, naming both versions; the fix is rebuilding whichever binary is stale and comparing `native version`), `dispatch_errors=<total>` and `dropped_trace_records=<total>`, and recent degraded handler/update errors appear as ` error event=<tag> name=<ErrorName> timestamp_ns=...` lines — a handler error no longer exits the app, so grep these to notice one happened. While `profile on` is active, a `frame_profile <stage>_p50_us=... <stage>_p90_us=... <stage>_max_us=... <stage>_n=...` line follows the header with per-stage frame timing. The header also carries `markup_watch=armed|off` — whether the markup hot-reload watch is armed (armed only in builds where the app wired `.markup` with a `watch_path` and `io`, or registered compiled fragments through `fragment_watch` — i.e. Debug dev builds).
- `windows.txt`: window list.
- `command.txt`: command input written by CLI and consumed by runtime. The slot is single-entry and the app consumes one command per presented frame: `native automate <command>` WAITS for the running app to consume it and prints `delivered <action> -> <dir>` on success — it refuses/fails loudly instead of silently overwriting an unconsumed command, and exits non-zero if the app never consumes it.
- `bridge-response.txt`: last bridge response.
- `screenshot-<view-label>.png`: deterministic reference-rendered PNG of a `gpu_surface` view, written by the `screenshot` command.
The runtime polls `command.txt`. After processing a command, it writes `done`.
## Debugging automation failures
If `native automate wait` times out with NO snapshot file at all, it prints a teaching error naming the automation dir it watched and pointing at `-Dautomation=true` and the working directory — start from that message. Otherwise:
1. Confirm the app is still running.
2. Confirm it was built with `-Dautomation=true`.
3. Confirm the runner passes `automation` into `Runtime.init`.
4. Check `.zig-cache/native-sdk-automation/snapshot.txt`.
5. Delete stale files in `.zig-cache/native-sdk-automation/` and restart the app.
6. Run with more tracing, for example `zig build run -Dtrace=all`.
If the CLI reports an automation protocol mismatch (or a snapshot with no protocol version): the `native` binary and the app were built from different framework versions — rebuild the older side (stale `zig-out/bin` copies of the CLI are the classic cause; `native version` prints the commit the binary was built from).
Stale INSTANCES are called out too: automate verbs print a LOUD warning when the publishing app's process started BEFORE the newest binary in `zig-out/bin` was built — a leftover instance from an earlier run impersonating the new build. Kill it and relaunch the fresh binary before trusting any snapshot.
If `snapshot` says no app connected:
- The automation directory may not exist yet.
- The app may be running from a different working directory.
- The app may be built without automation.
- The app may not have reached runtime startup.
If bridge automation fails:
- Check command name spelling.
- Check app handler registration.
- Check bridge policy origins for `zero://inline`.
- Check runtime permissions.
- Check that the handler returns valid JSON.
## CI and smoke tests
Use automation for minimal integration confidence:
```bash
zig build test-webview-smoke -Dplatform=macos
```
A good smoke test:
1. Builds an example with `-Dautomation=true` and `-Djs-bridge=true`.
2. Starts the app in a GUI-capable session.
3. Waits for readiness.
4. Verifies snapshot metadata (`automate assert` with the patterns that matter).
5. Sends `native.ping`.
6. Exercises builtin windows/WebViews if the app enables them.
7. Fails on timeout or unexpected bridge response.
Apps scaffolded with `native init --full` ship this as `.github/workflows/ci.yml` (the zero-config default scaffold skips CI): a null-platform `zig build test` job plus a Linux Xvfb smoke job that launches the binary, runs `automate wait`, asserts on the snapshot with `automate assert`, and checks a non-empty `automate screenshot` artifact. Extend that file rather than writing grep chains by hand.
Do not use automation for exhaustive UI testing. It is a runtime and bridge smoke layer.
## Notes
- Automation is compile-time gated: apps built without `-Dautomation=true` ignore automation files.
- Screenshots cover retained-canvas (`gpu_surface`) views only; WebView pixels are not captured.
- WebView DOM interaction is intentionally out of scope for this file-based automation layer.
- Use `native skills get core --full` for app architecture, bridge policy, packaging, and debugging context.
+252
View File
@@ -0,0 +1,252 @@
---
name: core
description: Core Native SDK guide for AI agents. Read this before explaining the Native SDK or changing a Native SDK app. Covers the mental model (native-rendered apps by default, WebView shells as a coexisting architecture), project structure, app.zon, App and Runtime patterns, frontend integration, web engines, JavaScript bridge commands, permissions, windows, WebViews, dialogs, packaging, debugging, testing, and when to load deeper references. For authoring native-rendered UI (Native markup .native views, Model/Msg/update, UiApp), load the native-ui skill. Use when the user asks what the Native SDK is, how to build or modify an app, how to package or debug it, or how to add native capabilities.
---
# Build Native SDK apps
The Native SDK is a cross-platform native app toolkit inspired by the web. The default app (`native init`) is native-rendered: a declarative Native markup view (`.native`) plus Zig logic on the `UiApp` loop, drawn by Native SDK's own engine — **load the `native-ui` skill for everything about authoring those** (markup grammar, bindings, messages, testing, hot reload). This skill covers the shared foundation and the WebView-shell architecture: apps that render web frontends in a WebView (`native init --frontend next|vite|react|svelte|vue`). Both architectures share windows, policies, lifecycle, commands, and platform services, and one app can mix native-rendered surfaces and WebViews. WebView engines: platform WebView (WKWebView on macOS, WebKitGTK on Linux) or Chromium through CEF where supported.
Agents should assume they do not know the Native SDK from general model knowledge. Read this skill first. For implementation work, run `native skills get core --full` so the referenced files are included in the CLI output.
## Mental model
- `App` describes the product: app state, name, WebView source, lifecycle callbacks, and optional bridge dispatcher.
- `Runtime` owns the event loop, windows, bridge dispatch, security checks, automation, tracing, platform services, and window state.
- `WebViewSource` tells the runtime what to load: inline HTML, a URL, or packaged assets from a local app origin.
- `app.zon` is the app manifest: identity, icons, windows, frontend assets, web engine, permissions, bridge policy, security policy, and packaging inputs.
- `src/runner.zig` is the generated runtime wiring. Edit it when adding runtime services, security policy, builtin bridge policy, tracing, automation, or platform setup.
- `src/main.zig` is app behavior. Edit it when changing app state, source selection, lifecycle callbacks, or app-defined bridge handlers.
- `frontend/` is normal web code. It talks to native Zig through `window.zero.invoke()` or builtin helpers when those are enabled.
## Task router
These references are included by `native skills get core --full`. Use them when the task touches the topic:
- Project creation, generated files, build steps: `references/project-anatomy.md`
- `App`, `Runtime`, callbacks, embedding, tests: `references/app-model-runtime.md`
- React/Vue/Svelte/Next/Vite, dev server, bundled assets: `references/frontend-assets.md`
- App-defined bridge commands, builtin commands, permissions, windows, WebViews, dialogs: `references/bridge-security-native-capabilities.md`
- Web engine choice, CEF, packaging, signing, doctor, logs, debugging: `references/web-engines-packaging-debugging.md`
- Running-app inspection and smoke tests: `native skills get automation`
- `zig build` fails on std APIs ("no member named 'cwd'", ArrayList `init`): `native skills get zig` — the Zig 0.16 idioms, indexed by compile error
## Quick start
Use the CLI for new apps:
```bash
npm install -g @native-sdk/cli
native init my_app --frontend next
cd my_app
zig build run
```
Frontend choices are `next`, `vite`, `react`, `svelte`, and `vue`. The first `zig build run` installs frontend dependencies, builds the native shell, and opens a desktop window.
## Workflow for existing apps
Before editing an existing Native SDK app:
1. Read `app.zon`, `src/main.zig`, `src/runner.zig`, and `build.zig`.
2. Identify whether the change is app metadata/policy, runtime wiring, app-native behavior, frontend behavior, packaging, or automation.
3. Follow the generated code and examples in the repository instead of inventing a new app layout.
4. Prefer exact security policy changes over broad allowances.
5. Validate with the narrowest useful command.
Common file ownership:
- `app.zon`: app identity, version, icons, windows, permissions, capabilities, bridge command policy, allowed origins, frontend dist/dev config, web engine, CEF config.
- `src/main.zig`: `App` state, source selection, lifecycle callbacks, custom bridge handlers.
- `src/runner.zig`: `Runtime.init`, platform selection, security policy, builtin bridge policy, `js_window_api`, automation server, trace sinks, panic capture, window state store.
- `build.zig`: build options, frontend build/dev/package steps, platform link setup, test steps.
- `frontend/`: web app implementation, `window.zero` calls, dev/build config.
## Core app model
The minimal Zig app returns `native_sdk.App` with `context`, `name`, and a WebView source:
```zig
const App = struct {
fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "my-app",
.source = native_sdk.WebViewSource.html("<h1>Hello from Native SDK</h1>"),
};
}
};
```
Use these source constructors:
- `native_sdk.WebViewSource.html(content)` for small inline demos.
- `native_sdk.WebViewSource.url(address)` for an explicit URL.
- `native_sdk.WebViewSource.assets(.{ .root_path = "frontend/dist", .entry = "index.html" })` for packaged frontend assets.
For framework apps, prefer a dynamic source so development loads the local dev server and production loads bundled assets:
```zig
fn source(context: *anyopaque) anyerror!native_sdk.WebViewSource {
const self: *App = @ptrCast(@alignCast(context));
return native_sdk.frontend.sourceFromEnv(self.env_map, .{
.dist = "frontend/dist",
.entry = "index.html",
});
}
```
`sourceFromEnv` reads `NATIVE_SDK_FRONTEND_URL`; otherwise it serves the configured asset directory. Use it for most framework apps.
## app.zon essentials
Keep `app.zon` as the source of truth for app-level behavior:
```zig
.{
.id = "com.example.my-app",
.name = "my-app",
.display_name = "My App",
.description = "One line about the app, shown in the About panel.",
.version = "0.1.0",
.icons = .{"assets/icon.png"},
.platforms = .{ "macos", "linux" },
.permissions = .{},
.capabilities = .{ "webview" },
.frontend = .{
.dist = "frontend/dist",
.entry = "index.html",
.spa_fallback = true,
.dev = .{
.url = "http://127.0.0.1:5173/",
.command = .{ "npm", "--prefix", "frontend", "run", "dev", "--", "--host", "127.0.0.1" },
.ready_path = "/",
.timeout_ms = 30000,
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.windows = .{
.{ .label = "main", .title = "My App", .width = 960, .height = 640, .restore_state = true },
},
}
```
Use exact local origins for dev servers. Add `zero://inline` only for inline HTML sources.
## Common implementation recipes
### Add a new framework app
Use `native init <path> --frontend <next|vite|react|svelte|vue>`. Then inspect the generated `app.zon`, `src/main.zig`, and `build.zig` before customizing. For framework behavior, keep frontend work in `frontend/` and use `sourceFromEnv` so development and packaged builds share one app shell.
### Add a native bridge command
1. Add state and a handler in `src/main.zig`.
2. Register the handler in `bridge()`.
3. Allow the command in `app.zon` and in the runtime bridge policy if the runner reads manifest policy into runtime.
4. Call it from JavaScript with `window.zero.invoke("namespace.command", payload)`.
5. Return valid JSON from Zig. Use `native_sdk.bridge.writeJsonStringValue()` for user-controlled strings.
Bridge calls are size-limited, origin-checked, permission-checked, and routed only to registered handlers.
### Add windows, child WebViews, or dialogs
Use builtin bridge commands only after enabling a policy for the exact commands and origins. Window and child WebView commands need the `window` permission when permissions are configured. Dialog commands are always default-deny and require explicit `builtin_bridge` policy. See `references/bridge-security-native-capabilities.md`.
### Choose a web engine
Default to `.web_engine = "system"` for small apps and native footprint. Use `.web_engine = "chromium"` plus `.cef` when the app needs a pinned Chromium platform or rendering consistency. Chromium apps must install/package the matching CEF layout.
### Package an app
Zero-config apps package WITHOUT ejecting — `native package` works directly on the zero-config build (`native eject` is only for owning the build files, never a packaging prerequisite):
```bash
native build
native package --target macos
```
Apps that own their build (ejected or scaffolded `--full`) wire the same step into the build graph: keep package metadata in `app.zon`, build the frontend assets, build the native binary, then package:
```bash
zig build package
native doctor --manifest app.zon --strict
```
Use signing and CEF options only when the product requires them.
## Development commands
For iterative frontend work, use the managed dev server flow:
```bash
zig build dev
```
Or run the CLI directly after building the binary:
```bash
native dev --manifest app.zon --binary zig-out/bin/MyApp
```
Vite usually uses `http://127.0.0.1:5173/`; Next.js usually uses `http://127.0.0.1:3000/`. The app WebView loads the dev URL directly, so framework HMR remains owned by Vite, Next.js, or the selected dev server.
## Security defaults
Treat WebView content as untrusted:
- List only needed `permissions` and `capabilities`.
- Prefer exact bridge command origins over `"*"`.
- Keep main-frame navigation allowlisted in `security.navigation.allowed_origins`.
- Keep external links denied unless the product explicitly needs them.
- Use a strict CSP for packaged frontend assets.
- Built-in dialogs are always default-deny and require explicit `builtin_bridge` policy.
- Child WebViews receive `window.zero` only when explicitly created with `bridge: true`.
Common bridge failure codes are `invalid_request`, `unknown_command`, `permission_denied`, `handler_failed`, `payload_too_large`, and `internal_error`.
## Validate changes
Useful commands:
```bash
zig build run
zig build dev
zig build test
zig build test-tooling
native validate app.zon
native doctor --manifest app.zon --strict
zig build package
```
Run BOTH `zig build` and `zig build test` before calling a change done: Zig's lazy analysis means code only tests reference (or only `main()` reference) can sit broken for weeks under the other command alone — tests never analyze `main`, so an API removed from std can keep "passing" until the app build finally touches it.
The toolkit requires Zig 0.16.0. When a build fails with "no member named" errors on std APIs (`std.fs.cwd`, `ArrayList.init`, `std.io`, `GeneralPurposeAllocator`), the code was written against older Zig idioms — `native skills get zig` maps each such compile error to the 0.16 idiom as this SDK writes it.
For GUI smoke tests, build with automation enabled and use the `automation` skill:
```bash
zig build run -Dplatform=macos -Dautomation=true
zig-out/bin/native automate snapshot
```
When changing app behavior, add focused Zig tests when the code can run headlessly. Use automation-based tests only for WebView/runtime integration that requires a GUI-capable session.
## Examples to inspect
- `examples/hello`: smallest inline HTML app.
- `examples/webview`: bridge and WebView runtime example.
- `examples/browser`: layered WebView/browser-style example.
- `examples/next`: Next.js with production assets.
- `examples/react`, `examples/svelte`, `examples/vue`: Vite frontend apps.
- `examples/ios`, `examples/android`: mobile host embedding examples.
## When answering users
Explain the Native SDK in concrete terms: Zig owns native app lifecycle and security; web UI renders in a WebView; the bridge is opt-in and policy controlled; `app.zon` is the manifest; framework frontend development uses a dev server in development and bundled assets in production. If asked to implement, read the app files first and make the smallest change in the correct layer.
@@ -0,0 +1,141 @@
# App Model and Runtime
Use this when editing `src/main.zig`, `src/runner.zig`, lifecycle behavior, runtime setup, or tests.
## `App`
A Native SDK app returns a `native_sdk.App` value:
```zig
const App = struct {
fn app(self: *@This()) native_sdk.App {
return .{
.context = self,
.name = "my-app",
.source = native_sdk.WebViewSource.html("<h1>Hello</h1>"),
.source_fn = source,
.start_fn = start,
.event_fn = event,
.stop_fn = stop,
};
}
};
```
Required fields:
- `context`: pointer to app state.
- `name`: app name for traces and automation snapshots.
- `source`: initial WebView source. Overridden by `source_fn` when present.
Optional callbacks:
- `source_fn`: dynamic source resolver.
- `start_fn`: called after runtime start and initial load.
- `event_fn`: receives lifecycle and runtime events.
- `stop_fn`: called before shutdown.
## `WebViewSource`
Choose one:
```zig
native_sdk.WebViewSource.html("<p>Inline</p>")
native_sdk.WebViewSource.url("http://127.0.0.1:5173/")
native_sdk.WebViewSource.assets(.{
.root_path = "frontend/dist",
.entry = "index.html",
.origin = "zero://app",
.spa_fallback = true,
})
```
Use inline HTML only for small examples and smoke tests. Use URL sources for explicit local/remote loading. Use assets for packaged apps.
## Runtime setup
Generated runners create a `Runtime` with platform services:
```zig
var runtime = native_sdk.Runtime.init(.{
.platform = my_platform,
.trace_sink = fanout.sink(),
.bridge = my_app.bridge(),
.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies },
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://app" } },
},
.js_window_api = true,
.window_state_store = state_store,
.automation = if (build_options.automation) automation_server else null,
});
try runtime.run(my_app.app());
```
`RuntimeOptions` fields agents commonly touch:
- `platform`: macOS, Linux, Windows, or `NullPlatform`.
- `trace_sink`: stdout/file/fanout trace destination.
- `bridge`: app-defined bridge dispatcher.
- `builtin_bridge`: policy for built-in windows, WebViews, and dialogs.
- `security`: permissions, navigation allowlist, external links.
- `automation`: file-based automation server.
- `window_state_store`: persisted window geometry.
- `js_window_api`: exposes `window.zero.windows` and `window.zero.webviews`.
## Windows from Zig
Use runtime methods for native window management:
```zig
const info = try runtime.createWindow(.{
.label = "tools",
.title = "Tools",
.default_frame = native_sdk.geometry.RectF.init(80, 80, 420, 320),
});
try runtime.focusWindow(info.id);
```
Window limits:
- Max windows: 16.
- Max label bytes: 64.
- Max title bytes: 128.
Persisted window state is keyed primarily by `label`, so labels should be stable.
## EmbeddedApp
Use `EmbeddedApp` when another host owns the main loop:
```zig
var embedded = native_sdk.embed.EmbeddedApp.init(my_app.app(), my_platform);
try embedded.start();
try embedded.frame();
try embedded.resize(new_surface);
try embedded.stop();
```
This is useful for mobile hosts, game engines, custom render loops, and headless tests. The repository includes iOS and Android examples that link `libnative-sdk.a` through Swift/Kotlin host apps.
## Headless tests
Use `NullPlatform` or `TestHarness` when GUI behavior is not required:
```zig
var null_platform = native_sdk.NullPlatform.init(.{});
var runtime = native_sdk.Runtime.init(.{
.platform = null_platform.platform(),
});
```
Good headless test targets:
- source selection
- bridge handler logic
- bridge policy enforcement
- lifecycle callbacks
- manifest/tooling behavior
Use automation smoke tests for real WebView/window integration.
@@ -0,0 +1,230 @@
# Bridge, Security, and Native Capabilities
Use this when adding JavaScript-to-Zig calls, builtin commands, permissions, windows, child WebViews, dialogs, navigation policies, or external links.
## Bridge architecture
JavaScript calls native Zig through:
```javascript
const result = await window.zero.invoke("native.ping", { source: "webview" });
```
The runtime:
1. Parses the JSON request.
2. Enforces message size limits.
3. Checks origin and permissions.
4. Looks up a registered handler.
5. Runs the handler and returns a JSON response.
Bridge commands are default-deny. A command must be registered in Zig and allowed by policy.
## Handler pattern
```zig
fn ping(context: *anyopaque, invocation: native_sdk.bridge.Invocation, output: []u8) anyerror![]const u8 {
_ = invocation;
const self: *App = @ptrCast(@alignCast(context));
self.ping_count += 1;
return std.fmt.bufPrint(output, "{{\"message\":\"pong\",\"count\":{d}}}", .{self.ping_count});
}
```
Dispatcher pattern:
```zig
fn bridge(self: *App) native_sdk.BridgeDispatcher {
self.handlers = .{.{ .name = "native.ping", .context = self, .invoke_fn = ping }};
return .{
.policy = .{ .enabled = true, .commands = &policies },
.registry = .{ .handlers = &self.handlers },
};
}
```
When returning user-controlled strings, escape them:
```zig
return native_sdk.bridge.writeJsonStringValue(output, user_name);
```
## Size limits
- Request message: 16 KiB.
- Response: 16 KiB.
- Handler result: 12 KiB.
- Request ID: 64 bytes.
- Command name: 128 bytes.
For large data, do not force everything through one bridge response. Use native files/resources or chunking patterns.
## Security policy
Core defaults:
- No permissions granted unless listed.
- Bridge commands denied unless policy allows them.
- Navigation blocked unless origin is allowlisted.
- External links denied unless configured.
- Dialog builtin commands always denied unless explicitly listed in `builtin_bridge`.
Manifest examples:
```zig
.permissions = .{ "window" },
.capabilities = .{ "webview", "js_bridge" },
.bridge = .{
.commands = .{
.{ .name = "native.ping", .origins = .{ "zero://app" } },
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
.external_links = .{ .action = "deny" },
},
},
```
Prefer exact origins over `"*"`. Use `"*"` only for commands that expose no native state and only when the project already accepts that risk.
## Builtin commands
The Native SDK includes builtin bridge commands for windows, layered WebViews, and dialogs. These are controlled separately from app-defined commands via `builtin_bridge`.
Window commands:
- `native-sdk.window.list`
- `native-sdk.window.create`
- `native-sdk.window.focus`
- `native-sdk.window.close`
Layered WebView commands:
- `native-sdk.webview.create`
- `native-sdk.webview.list`
- `native-sdk.webview.setFrame`
- `native-sdk.webview.navigate`
- `native-sdk.webview.setZoom`
- `native-sdk.webview.setLayer`
- `native-sdk.webview.close`
Dialog commands:
- `native-sdk.dialog.openFile`
- `native-sdk.dialog.saveFile`
- `native-sdk.dialog.showMessage`
Enable explicitly:
```zig
const app_permissions = [_][]const u8{native_sdk.security.permission_window};
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://app" } },
},
.builtin_bridge = .{
.enabled = true,
.commands = &.{
.{ .name = "native-sdk.window.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.openFile", .origins = .{ "zero://app" } },
},
},
```
`js_window_api = true` exposes `window.zero.windows.*` and `window.zero.webviews.*`, but it does not bypass origin or permission checks.
## Windows from JavaScript
```javascript
const win = await window.zero.windows.create({
label: "tools",
title: "Tools",
width: 420,
height: 320,
});
const all = await window.zero.windows.list();
await window.zero.windows.focus(win.id);
await window.zero.windows.close(win.id);
```
Window state persistence uses stable labels. Use meaningful labels like `main`, `settings`, `tools`, or `preview`.
## Layered WebViews
Child WebViews are native WebViews layered inside a native window:
```javascript
const preview = await window.zero.webviews.create({
label: "preview",
url: "https://example.com",
frame: { x: 24, y: 24, width: 480, height: 320 },
layer: 10,
bridge: false,
});
await preview.setZoom(1.25);
await preview.setLayer(20);
await preview.close();
```
Rules:
- WebView URLs must pass navigation policy.
- Commands target only the calling native window.
- `main` is reserved for the startup WebView.
- Child WebViews receive `window.zero` only with `bridge: true`.
- Backend gaps should reject with `invalid_request`.
## Dialogs
Dialogs require explicit `builtin_bridge` policy.
```javascript
const files = await window.zero.invoke("native-sdk.dialog.openFile", {
title: "Select a file",
defaultPath: "/home",
allowMultiple: true,
allowDirectories: false,
});
const path = await window.zero.invoke("native-sdk.dialog.saveFile", {
title: "Save as",
defaultName: "untitled.txt",
});
const result = await window.zero.invoke("native-sdk.dialog.showMessage", {
style: "warning",
title: "Confirm",
message: "Delete this item?",
primaryButton: "Delete",
secondaryButton: "Cancel",
});
```
Use native dialogs for trusted app UI. Do not expose arbitrary filesystem access to remote or untrusted origins.
## Error handling
JavaScript bridge calls reject with `error.code`:
- `invalid_request`: malformed input, unsupported operation, denied navigation URL, missing target, duplicate/reserved label.
- `unknown_command`: no registered handler.
- `permission_denied`: origin or permission failed.
- `handler_failed`: handler returned an error.
- `payload_too_large`: request too large.
- `internal_error`: unexpected runtime failure.
Always handle errors in frontend code:
```javascript
try {
await window.zero.invoke("native.save", payload);
} catch (error) {
console.error(error.code, error.message);
}
```
@@ -0,0 +1,124 @@
# Frontend and Assets
Use this when working with React, Vue, Svelte, Next.js, Vite, dev servers, HMR, static assets, or packaged frontend output.
## Recommended source pattern
Framework apps should use a dynamic source:
```zig
fn source(context: *anyopaque) anyerror!native_sdk.WebViewSource {
const self: *App = @ptrCast(@alignCast(context));
return native_sdk.frontend.sourceFromEnv(self.env_map, .{
.dist = "frontend/dist",
.entry = "index.html",
});
}
```
`sourceFromEnv` checks `NATIVE_SDK_FRONTEND_URL`. If set, the WebView loads the dev server URL. If not set, it serves packaged assets from `dist`.
## Manifest frontend config
```zig
.frontend = .{
.dist = "frontend/dist",
.entry = "index.html",
.spa_fallback = true,
.dev = .{
.url = "http://127.0.0.1:5173/",
.command = .{ "npm", "--prefix", "frontend", "run", "dev", "--", "--host", "127.0.0.1" },
.ready_path = "/",
.timeout_ms = 30000,
},
},
```
Fields:
- `dist`: built frontend output path.
- `entry`: HTML entry file within `dist`.
- `spa_fallback`: serve `entry` for unknown routes.
- `dev.url`: local dev server URL.
- `dev.command`: command the Native SDK can spawn.
- `dev.ready_path`: path to poll for readiness.
- `dev.timeout_ms`: readiness timeout.
## Dev server workflow
Use:
```bash
zig build dev
```
Or directly:
```bash
native dev --manifest app.zon --binary zig-out/bin/MyApp
```
The dev command starts the frontend process, waits for readiness, sets `NATIVE_SDK_FRONTEND_URL`, launches the native shell, and terminates the frontend when the shell exits.
Framework defaults:
- Vite, React, Vue, Svelte: `http://127.0.0.1:5173/`, command `npm run dev -- --host 127.0.0.1`.
- Next.js: `http://127.0.0.1:3000/`, command `npm run dev -- --hostname 127.0.0.1`.
## Production assets
For packaged builds, serve local assets from `zero://app`:
```zig
return native_sdk.frontend.productionSource(.{
.dist = "frontend/dist",
.entry = "index.html",
});
```
The package/build flow should build the frontend before packaging. `zig build bundle-assets` and `native bundle-assets` copy the configured dist directory into build/package output.
## Security and frontend origins
Add exact origins to `security.navigation.allowed_origins`:
```zig
.security = .{
.navigation = .{
.allowed_origins = .{
"zero://app",
"http://127.0.0.1:5173",
},
},
},
```
Use `zero://inline` only for inline examples. Do not allow `"*"` for production navigation.
For packaged HTML, start with a strict CSP:
```html
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'">
```
For dev servers, keep development CSP separate and add only the local dev/WebSocket endpoints required by the framework.
## Frontend to native calls
Use:
```javascript
const result = await window.zero.invoke("native.ping", { source: "webview" });
```
For builtin windows/WebViews helpers, enable `js_window_api` and policy in the runner. For custom commands, register handlers in Zig and allow the command in policy.
## Example projects
- `examples/next`: Next.js app with production assets under `frontend/out`.
- `examples/react`: React with Vite.
- `examples/svelte`: Svelte with Vite.
- `examples/vue`: Vue with Vite.
When unsure about frontend build commands or output directories, inspect the matching example before editing.
@@ -0,0 +1,108 @@
# Project Anatomy
Use this when creating, orienting in, or restructuring a Native SDK app.
## Generated project files
A zero-config app ships no build files at all — just `app.zon` + `src/` (+ `assets/`); the `native dev|test|build` verbs synthesize the build graph into `.native/build/` (gitignored). `build.zig`/`build.zig.zon` appear only in apps that own their build (`native eject`, the `--full` scaffold, or an expanded example):
- `build.zig`: Zig build graph. Expanded scaffolds expose platform selection, trace mode, debug overlay, automation, JS bridge, web engine overrides, frontend install/build/dev steps, tests, and package steps.
- `build.zig.zon`: Zig package manifest and dependency declaration.
- `app.zon`: app manifest read by CLI/build/package/doctor tooling.
- `src/main.zig`: app state, `app()` method, source resolver, optional bridge dispatcher, lifecycle callbacks.
- `src/runner.zig`: platform and runtime setup: native backend, trace sinks, panic capture, log paths, state store, security policy, builtin bridge policy, automation.
- `assets/`: icons and other package resources.
- `frontend/`: framework app when using Next, Vite, React, Svelte, or Vue.
## app.zon responsibilities
Keep product-level metadata and policies in `app.zon`:
```zig
.{
.id = "com.example.my-app",
.name = "my-app",
.display_name = "My App",
.version = "0.1.0",
.icons = .{"assets/icon.png"},
.platforms = .{ "macos", "linux" },
.permissions = .{ "window" },
.capabilities = .{ "webview", "js_bridge" },
.bridge = .{
.commands = .{
.{ .name = "native.ping", .origins = .{ "zero://app" } },
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
.external_links = .{ .action = "deny" },
},
},
.frontend = .{
.dist = "frontend/dist",
.entry = "index.html",
.spa_fallback = true,
.dev = .{
.url = "http://127.0.0.1:5173/",
.command = .{ "npm", "--prefix", "frontend", "run", "dev", "--", "--host", "127.0.0.1" },
.ready_path = "/",
.timeout_ms = 30000,
},
},
.web_engine = "system",
.windows = .{
.{ .label = "main", .title = "My App", .width = 960, .height = 640, .restore_state = true },
},
}
```
Important manifest fields:
- `id`: reverse-DNS bundle identifier. Used for bundle metadata and log/state paths.
- `name`: short machine name.
- `display_name`: human app name — shown by the application menu, Dock, app switcher, and About panel in dev runs and packaged bundles alike.
- `description`: optional one-line About-panel description (max 256 bytes, single line).
- `version`: package and bundle version — also shown in the About panel.
- `icons`: package resources.
- `platforms`: package targets.
- `permissions`: runtime grants checked by bridge and builtin commands.
- `capabilities`: broad feature declarations.
- `bridge.commands`: app-defined command allowlist.
- `security.navigation.allowed_origins`: main-frame navigation allowlist.
- `security.navigation.external_links`: external link policy.
- `frontend`: production asset and dev server config.
- `web_engine`: `system` or `chromium`.
- `cef`: CEF layout config for Chromium.
- `windows`: initial window definitions.
## Build steps to know
Common generated steps:
```bash
zig build run
zig build dev
zig build test
zig build package
zig build frontend-install
zig build frontend-build
```
Repository-level useful steps:
```bash
zig build test
zig build test-tooling
zig build test-webview-smoke -Dplatform=macos
zig build test-package-cef-layout -Dplatform=macos
```
## Layering rule
- If changing app identity, packaging inputs, permissions, origins, windows, frontend dist/dev paths, or web engine, update `app.zon`.
- If changing runtime services, platform setup, automation, logging, security wiring, or builtin bridge policy, update `src/runner.zig`.
- If changing native business behavior, lifecycle callbacks, bridge handlers, or source selection, update `src/main.zig`.
- If changing UI, routes, CSS, or web calls, update `frontend/`.
Do not put package metadata in Zig app state unless the generated project already does that. Do not bypass `app.zon` policy for convenience.
@@ -0,0 +1,199 @@
# Web Engines, Packaging, and Debugging
Use this when choosing system WebView vs Chromium, installing CEF, packaging, signing, running doctor, finding logs, or debugging runtime behavior.
## Web engine choice
Default:
```zig
.web_engine = "system",
```
System mode:
- Uses the OS web engine.
- macOS: WKWebView.
- Linux: WebKitGTK.
- Smallest app footprint.
- Fastest startup.
- Rendering depends on the user's OS.
Chromium mode:
```zig
.web_engine = "chromium",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
```
Chromium mode:
- Bundles CEF.
- Gives predictable Chromium behavior.
- Increases package size and startup cost.
- Requires matching CEF layout at build and package time.
Use Chromium when the product needs a pinned web platform, complex frontend rendering consistency, or Chromium-only behavior. Otherwise prefer `system`.
## CEF setup
Install the prepared runtime:
```bash
native cef install
native doctor --manifest app.zon
```
Pin CEF in app setup or CI when reproducibility matters:
```bash
native cef install --version <version>
```
Useful overrides:
```bash
zig build run -Dweb-engine=chromium -Dcef-dir=third_party/cef/macos
zig build run -Dweb-engine=chromium -Dcef-auto-install=true
native package --web-engine chromium --cef-dir third_party/cef/macos
```
Normal product configuration should live in `app.zon`; CLI/build flags are for temporary overrides.
## Packaging
Simple path:
```bash
zig build package
```
CLI path:
```bash
native package --target macos --manifest app.zon --binary zig-out/bin/MyApp
```
Important package manifest fields:
- `id`: bundle ID, desktop ID, log/state prefix.
- `display_name`: app/menu/window title fallback.
- `version`: package metadata.
- `icons`: copied into package resources.
- `platforms`: intended package targets.
- `frontend`: asset directory and entry file.
- `web_engine` and `cef`: engine and Chromium runtime config.
For frontend apps, package the built frontend assets. The build step usually wires this automatically. If using CLI directly, pass `--assets frontend/dist`.
## macOS packages
`zig build package` creates a `.app` bundle with:
- `Contents/MacOS/<binary>`
- `Contents/Resources/AppIcon.icns` (generated from the square `assets/icon.png`/`.svg` source in `.icons`; a prebuilt `.icns` ships untouched under its own name)
- `Contents/Info.plist`
- `Contents/Resources/dist/` when frontend assets are configured
- `Contents/Frameworks/Chromium Embedded Framework.framework` for Chromium apps
macOS minimum system version is 11.0.
Signing modes:
```bash
native package --target macos --signing none
native package --target macos --signing adhoc
native package --target macos --signing identity --identity "Developer ID Application: Your Name"
```
For Chromium apps, verify the CEF framework and resources are included and signed before notarization.
## Linux and Windows packages
Linux creates an install tree with:
- `bin/<name>`
- `share/applications/<name>.desktop`
- icons under `share/icons/hicolor/...`
Windows packaging is early support and creates a directory-based distributable layout.
Shortcut commands:
```bash
native package-linux --binary zig-out/bin/MyApp
native package-windows --binary zig-out/bin/MyApp.exe
```
## Doctor and validation
Validate manifest schema:
```bash
native validate app.zon
```
Check environment and package readiness:
```bash
native doctor
native doctor --manifest app.zon --strict
native doctor --manifest app.zon --web-engine chromium --cef-dir third_party/cef/macos
```
Doctor checks:
- host platform
- WebView availability
- manifest validity
- log directory writability
- CEF layout when Chromium is selected
- signing tools
Use `--strict` in CI or before release so warnings fail the command.
## Debugging
Trace modes:
- `off`
- `events`
- `runtime`
- `all`
Build/run flags commonly include:
```bash
zig build run -Dtrace=all
zig build run-webview -Ddebug-overlay=true
```
Log defaults:
- macOS: `~/Library/Logs/<bundle-id>/native-sdk.jsonl`
- Linux: `~/.local/state/<bundle-id>/logs/native-sdk.jsonl`
- Windows: `%LOCALAPPDATA%\<bundle-id>\Logs\native-sdk.jsonl`
Environment variables:
```bash
NATIVE_SDK_LOG_DIR=/tmp/my-logs zig build run
NATIVE_SDK_LOG_FORMAT=text zig build run
```
Panic capture:
```zig
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
```
Generated runners usually install panic capture so crashes write `last-panic.txt` and append a fatal trace record.
## Common failures
- App window opens blank: check `WebViewSource`, `frontend.dist`, `frontend.entry`, and allowed origins.
- Dev server never loads: check `app.zon frontend.dev.url`, command, readiness path, and `NATIVE_SDK_FRONTEND_URL`.
- Bridge call rejects with `permission_denied`: check command origin and permissions in policy.
- Bridge call rejects with `unknown_command`: handler was not registered or command name differs.
- Chromium app fails at launch: check CEF layout, version mismatch, bundle Frameworks layout, and signing.
- Package misses frontend: check `frontend.dist`, frontend build step, and `--assets`.
File diff suppressed because it is too large Load Diff
+493
View File
@@ -0,0 +1,493 @@
---
name: ts-core
description: Authoring guide for TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset and compiled ahead-of-time to arena-backed Zig by the @native-sdk/core transpiler. Use when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1060), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events).
---
# Author app cores in the TypeScript subset
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. You write it as a TypeScript module rooted at `src/core.ts` - splitting into more modules under `src/` when it grows (see "Splitting a core into modules") - and the `@native-sdk/core` transpiler compiles the whole import graph to Zig at build time. No JS engine ships in the binary — the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason. The same file is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, so you can poke behavior with plain node scripts before the native build.
A whole TS app is three files of truth and zero Zig: `src/core.ts` (this guide; plus any modules it imports under `src/`), `src/app.native` (the markup view over the core's emitted model), and `app.zon` (windows, identity, permissions). `native init` scaffolds exactly that; the build detects `src/core.ts` in the tree (never a flag or config — a tree with both `src/core.ts` and `src/main.zig` is a teaching error) and generates the wiring outside the app. The loop:
```sh
native dev --core # the fastest loop: run the core under node's virtual host —
# dispatch Msgs as JSON lines ({"kind":"add"}, {"$bytes":"…"}
# for bytes payloads, {"advance":1000} to run virtual timers),
# watch the model + effect transcript. Logic only, no renderer.
native dev # build and run the real app (markup hot reload)
native check # subset-check core.ts + validate markup + app.zon
native build # ReleaseFast binary; native test runs the app's tests
```
The complete reference app in this idiom is `examples/soundboard-ts` in the SDK repo: the soundboard music library as three files and zero Zig — const catalog tables, REAL audio through the `Cmd.audioPlay` stream, scrub-to-seek on a markup slider, a motion-gated `Sub.timer` playback clock, the full text-edit engine on a search field, controlled scroll, registered cover assets, the width-adaptive grid through the frame channel, clipboard, and context menus, with an end-to-end suite driving the shipping markup.
## The contract
```ts
export interface Model { /* readonly data fields only */ }
export type Msg =
| { readonly kind: "add" }
| { readonly kind: "toggle"; readonly id: number };
// ...one arm per thing that can happen; at least two arms
export function initialModel(): Model { /* pure */ }
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
// one case per arm; the switch must be exhaustive (no default needed
// once every arm is present — a missing arm is a build error)
}
}
```
- `update` is pure and synchronous: next model out, plus optionally command data describing effects. When a dispatch needs an effect, declare the return type `Model | [Model, Cmd<Msg>]` and return `[nextModel, cmd]` — the runtime interprets the command after the model commits and dispatches any result back to you as a `Msg`. `initialModel` may return the same pair (`[Model, Cmd<Msg>]`) to run a boot effect once at install, and an app that needs recurring timers exports `subscriptions(model): Sub<Msg>`. See "Effects are Cmd data" below.
- Exported helper functions (`export function doneCount(model: Model): number`) compile to public Zig functions — and every exported helper taking exactly ONE Model parameter also becomes a Model declaration markup binds by the helper's own name (`{doneCount}`), so derived values need no model field. One emitted name per member: a helper that collides with a field (or another helper) is a taught NS1031.
- Update-only state (fields, helpers, or Msg kinds nothing in markup binds or dispatches — host-fired timer arms, persistence bookkeeping) is declared once: `export const viewUnbound = ["nextId", "tick"] as const;`. It emits as the `view_unbound` opt-out `native check`'s unbound-state lint reads; a name outside the model surface is a taught NS1032. Entries are the TypeScript names exactly as declared (`"nextId"`) and Msg kinds as their `kind` tags — the same names markup binds, because there are no other names.
- Names: your names are your names — fields, helpers, and locals emit into Zig with their TS spellings (`doneToday` stays `doneToday`), and markup binds them verbatim. String-literal unions emit as native enums.
## The arena mental model
Everything `update` builds lives in a per-dispatch bump arena that is freed wholesale after the returned model is committed, so spreads, `map`, and `filter` are cheap by construction. At commit, only nodes your update actually created are copied into the persistent model heap — everything you spread through unchanged is shared with the previous model for free.
That is why the immutable style is not a performance tax: `{ ...model, tasks: model.tasks.map(...) }` copies one small struct and one pointer array, never the world.
Both regions have FIXED, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model per space. They are comptime knobs of the emitted core — `--frame-cap <bytes>` / `--heap-cap <bytes>` on the transpiler CLI (`frameCap`/`heapCap` in the API) — and v1 never grows them at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a loud runtime panic naming the knob to raise, never silent corruption.
## What the subset means
The subset is TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Concretely: every basic statement, operator, and declaration form of the language compiles (every loop shape including `do...while`, labels with labeled `break`/`continue`, `switch` with `default`, the full assignment-operator family, `**` and the shifts, const record destructuring, namespace imports over your own modules). What does not compile falls into exactly two families, each with a named teaching rule: the ECOSYSTEM the binary cannot carry (npm packages, Node/DOM APIs, regex/JSON/Promise/generator machinery, `eval` — no JS engine ships), and the constructs that would break a core's guarantees (purity and determinism, fixed shapes, one text representation, functions as declarations not values, static types with no runtime tags). Classes and exceptions are NOT in either family: data classes and `throw`/`try`/`catch`/`finally` compile (see below) — only their guarantee-breaking tails (inheritance, unsafe finally, untagged thrown values) teach. A construct that fails with a generic error instead of a teaching rule is a transpiler bug — the grammar matrix test (`grammar_matrix.test.ts`) pins every grammar production to its verdict so no silent gap can appear.
The banned families at a glance (each diagnostic names the fix and the reason at the site):
- **No JS engine ships**: `eval`, `new Function`, dynamic `import()`, `debugger` (NS1013); regexes (NS1040); BigInt/Symbol (NS1044); generators (NS1042); async/Promises (NS1002); npm imports (NS1035).
- **Purity and determinism**: mutation of SHARED data — parameters, model/msg trees, module tables, escaped locals (NS1001/NS1022/NS1051; your own function-local scratch arrays mutate freely, see "Local mutation" below), module-level `let` (NS1010), ambient time/randomness/IO (NS1005), effects outside the Cmd/Sub return paths (NS1017/NS1025).
- **Fixed shapes and layouts**: the class machinery beyond data classes — `extends`/`super`/`abstract` (NS1055), accessors/`#`-privates/class expressions/`this`-as-a-value (NS1056/NS1006), mutable statics (NS1010), generic classes (NS1053) — plus `delete`/getters/setters/computed keys (NS1012), `for`/`in` (NS1009), Map/Set (NS1011), runtime type/shape tests — `typeof`/`in`/`instanceof`/`Object.*` (NS1041). Data classes themselves compile — `static` methods, `static readonly` consts, and erased `private`/`protected` included (see "Data classes" below).
- **One text representation**: string indexing (NS1004), `+` concatenation and tagged templates (NS1018), `string` model fields (NS1024), the byte-text stays-out spellings — `charCodeAt`/`normalize`/`replace` and friends on bytes teach the byte-honest form (NS1060; the supported method surface is under "Text is bytes").
- **Functions are declarations — or const local helpers**: nested function declarations, non-const function values, `?.()` (NS1046), and const helpers that capture/escape/under-annotate (NS1054 — the legal shape is below under "Local function values"); fixed arity — no defaults, rest, `arguments`, call spreads (NS1019); generics live on module-level declarations and monomorphize per call site (NS1050/NS1053).
- **The mapping stays exact**: `var` hoisting (NS1049), loose `==` (NS1048), comma/void/assignment-as-value outside a for-header (NS1043), array/parameter destructuring (NS1045), `export default`/`export =`/`export * from` (NS1047 — export lists and named value re-exports compile), namespace-alias-as-value and SDK namespace imports (NS1039).
## What compiles (v1)
Model and message shapes:
- `interface` with `readonly` fields; nested interfaces; `T | null` for optional data.
- Model field types: `number`, `boolean`, string-literal unions (`"all" | "active" | "done"` → native enum), numeric-literal unions, `Uint8Array` (bytes), a nested interface, `readonly Interface[]` (arrays of object types), primitive arrays (`readonly number[]`, `readonly boolean[]`, arrays of literal-union tags), a tag-discriminated union (`{ kind: "list" } | { kind: "detail"; note: Note }` — arms may carry records, bytes, and primitive arrays), and `T | null` over any of these. Model unions compile to native tagged unions; switching arms in `update` retires the old arm's payload automatically at commit.
- `Msg`: a discriminated union on a `readonly kind` string tag, with primitive / bytes / interface payload fields. It must be a real union — give it at least two arms, or TypeScript collapses the alias to a plain object type and the transpiler rejects it.
Logic:
- `switch` on any union's `kind` tag — `msg.kind` (the Msg dispatch) and model-field unions (`switch (model.view.kind)`) alike — with member case labels, label stacking (`case "a": case "b": body`), `break`, and a trailing `default` covering the unnamed arms (without a `default` the switch must be exhaustive — NS1015; with every arm named the `default` is JS dead code and emits nothing) — and `switch` on a string-literal-union or numeric-literal-union *value* (`switch (model.filter)`) — an uncovered member skips the switch exactly like JS (a `default` anywhere but last is a taught stop in both forms) — and `switch` on a plain `number` or `string` value, lowered to an if/else chain with exact JS semantics: strict equality per case (NaN matches nothing, `-0` matches `0`, strings compare contents), cases tested in source order, `default` matching only after every case misses wherever it sits (an empty `default:` stacking onto the next body included); `if`/`else`; classic `for (let i = 0; ...)` including countdowns (`i--`, `i -= k`), multi-counter inits (`let lo = 0, hi = n`), and comma incrementors (`lo++, hi--` — the for-header is the one home for comma sequences); `do { ... } while (cond)` (the body runs before the first test; `continue` jumps to the test, exactly node); `for (const x of xs)` over arrays and `Uint8Array`, with `break`/`continue`, plus the indexed pair form `for (const [i, x] of xs.entries())` (exactly the `[index, element]` two-identifier binding — the index is the loop index, integer-classed; other tuple shapes stay taught); `while`; labeled statements on loops and blocks with labeled `break`/`continue` (`outer: for (...) { ... continue outer; }` — a labeled `continue` in a classic for still runs the incrementor, like JS); `let` locals with reassignment (and `let x: number;` declared-then-assigned); ternaries; `&&`/`||`/`!`; the empty statement `;`.
- `const { total, done: doneCount } = stats;` — record-field destructuring into const locals (a compile-time alias per field, renames included). Array patterns, parameter patterns, defaults, rest, and nesting are taught (NS1045 — positions can be silently absent in JS; fields cannot).
- `import * as util from "./util.ts"` — a namespace import over your own modules is pure dot-syntax: `util.helper(x)`, `util.CONST`, and `util.Cfg` in type positions all resolve to the target module's flat emitted names. The alias is not a value (storing or passing `util` itself is taught), and the intrinsic `@native-sdk/core` module is always imported by name (NS1039 — the purity rules recognize `Cmd`/`Sub`/`asciiBytes` by their imported names).
- Object spread `{ ...model, field: v }`, array spreads in any shape — append `[...xs, x]`, prepend `[x, ...xs]`, multi-spread `[...a, x, ...b]` (each compiles to one exact-size copy) — `.length`, indexing `xs[i]`.
- Array methods, lowered to inlined loops and exact-size arena copies: `.map` / `.filter` / `.find` / `.findIndex` / `.some` / `.every` / `.reduce` / `.toSorted` / `.slice` / `.concat` / `.indexOf` / `.includes`. `.map` is type-changing — `tasks.map((t) => t.id)` produces a number array, `t => t.title` a bytes array, and a callback that can return `null` produces an optional-element array. Callbacks on map/filter/find/findIndex/some/every may take the `(element, index)` pair — the index is the loop index, integer-classed (`.reduce` stays `(acc, x)`: its index parameter is not in v1, and no callback takes the third JS parameter, the array itself — reference the array by name). Array-method calls may sit directly in `if`/`else if` and ternary conditions (`if (xs.some((x) => x > 3))`) — the scan lowers to a loop just before the branch; a `while` condition cannot (it re-evaluates per iteration — hoist into the loop body or restructure). Callbacks are arrows (expression or block body), inline `function` expressions, or a BARE REFERENCE to a module-level function or const helper (`xs.map(encodeTurn)`, `xs.toSorted(byAscending)` — the referenced body inlines exactly like the arrow spelled at the site); in a block body every code path must end in an explicit `return` (falling off the end would be JS `undefined`, which has no mapping — a taught stop). JS semantics hold exactly: `.slice` resolves negative and out-of-range indices the JS way, `.indexOf` never matches `NaN` while `.includes` does, `.some`/`.every` keep their vacuous defaults on empty arrays, and `.reduce` needs its initial value (the no-initial form throws on an empty array in JS, so it is a taught NS1007 — pass the starting accumulator). `.indexOf`/`.includes` work on scalar elements (numbers, tags, booleans); on record arrays JS compares references, which has no native mapping — match a field with `.find`/`.findIndex` instead.
- **Local mutation — your own scratch is yours; shared data is immutable.** An array your function CREATES — an array literal (`const stack: number[] = []`, `const st = [1, 2, 3]`) or a fresh copy (`.slice()` / `.map()` / `.filter()` / `.concat()` / `.toSorted()`) — is locally owned, and the full mutating method set works on it with exact JS semantics: `push(...items)`, `pop()`, `shift()`, `unshift(...items)`, `splice(start, deleteCount?, ...items)` (negative/overshooting indices clamp the JS way; the value is the removed array, also yours), `reverse()`, `fill(v, start?, end?)`, in-place `sort(cmp)`, and indexed writes `xs[i] = v`. A parser stack, a work queue, a copy-then-sort — all legal, deterministic, and byte-identical to node. Ownership ends at the first ESCAPE: once the array is returned from a callback, stored into a record/array/model, aliased by a second binding (`const b = a`), or passed where the callee could keep or mutate it, mutating it afterwards is a taught NS1051 — finish mutating first, then let it escape (an early-exit `return` is fine: execution ends there, so mutations on the other path stay legal). Two loosenings keep real code flowing. BORROWING: passing an owned array into a `readonly T[]` parameter is NOT an escape when the callee only READS it (element/property access, iteration, spreads, further borrowing passes — no return of it, no store, no onward pass into a mutable position; recursion over borrowed slices included), so measure-mutate-measure loops work (`total(out); out.push(x); total(out)`). REASSIGNED-OWNING: a `let` binding whose EVERY assignment installs a fresh owning construction (a literal or a copy — `w = xs.filter(...)`, `acc = []`) stays owned through the reassignments; ONE mixed assignment (an alias, a parameter, a helper result) and the binding never owns (NS1001 names it). Never owned: parameters, model/msg data, module `const` tables, aliases, mixed reassigned bindings, and arrays produced by helper calls (copy with `.slice()` to own one). After the value escapes it is an ordinary immutable value; the commit walkers and sharing discipline are unaffected because ownership ended before the escape.
- Local-mutation shape notes: `push`/`unshift` return the new length in JS, which has no mapping — mutate as a statement and read `.length` after; `sort`/`reverse`/`fill` return the same array — mutate as a statement, then use the array by name (`return copy.sort(cmp)` is a taught stop; the canonical form is `const copy = xs.slice(); copy.sort(cmp); return copy;`); `pop()`/`shift()` return `T | undefined` — the same one-empty the `.find` miss produces, so test `=== undefined` or fold with `??` (`stack.pop() ?? fallback`); spread arguments (`out.push(...xs)`) stay taught — one element per iteration; `xs[xs.length] = v` on an owned array IS a push (the one growth shape — compound forms like `xs[xs.length] += v` read the missing slot first and stay taught), and other out-of-bounds writes are JS sparse arrays with no mapping (they trap on the native bounds check in safe builds — keep writes inside `0..length-1`); changing the LENGTH of the array a `for...of` (or one of its own callbacks) is iterating is a taught stop (JS walks the live array; fixed-length writes during iteration are fine and identical to node); `copyWithin` stays out of v1 (splice/fill cover it).
- `.toSorted(cmp)` sorts a copy in one expression; `.sort(cmp)` sorts in place on an array you own (on shared data it keeps the NS1022 teaching, which names the copy idiom). Both comparators follow the same rules: return a sign — `(a, b) => a - b` for ascending numbers, or explicit -1/0/1 branches; a boolean comparator is wrong in JS itself (false claims equality) and is rejected by the types plus a taught NS1023. The comparator-less arity sorts by string ToString order in JS (`[10, 9]` stays `[10, 9]`), which has no float-text mapping — pass a comparator. Both sorts are stable exactly like JS: comparator 0 (or NaN) keeps the original order of the pair. One honesty note: a comparator that is inconsistent over the actual data (e.g. `a - b` when elements can be `NaN`) is implementation-defined in JS itself, so node and native may then disagree — keep comparators consistent.
- A `.find` miss is the tier's one empty value: JS spells it `undefined`, so test the result with `=== undefined` (never `=== null` — the checker teaches the difference) or fold it away with `??`: `tasks.find((t) => t.id === id) ?? fallback`.
- Optional chaining `?.` on property chains (`model.sel?.at ?? 0`), element hops (`m?.xs[0] ?? 0`, `xs?.[i] ?? d`), and method hops on supported receivers (`xs?.slice(0, 2)`, `xs?.includes(3) ?? false` — every mapped array/bytes method): each hop null-propagates exactly like JS, and the chain value is optional — end it in `??` or compare it against a real value. A `?.` chain compared against `null`/`undefined` is a taught error (NS1021), and `g?.()` on a function value stays taught.
- Null-guard narrowing through `&&`/`||` chains, exactly the way TS narrows: `if (x !== null && x.items.length > 0)`, the flipped order (`null !== x`), the `||` dual (`x === null || x.items.length === 0`, including as an early-exit guard — the code after the exit stays narrowed), ternary conditions (`x !== null && x.at > 0 ? x.at : -1`), and `while (cur !== null && cur.n > 0)` loops (re-tested per iteration; assigning the guarded local drops the narrowing for what follows, like TS). Relational comparisons on guarded optionals (`cls !== null && cls < lim`) work too.
- Nullish `??`, comparisons (including `===` on `string`-typed values — content equality, same as node; `==`/`!=` are taught NS1048 — coercion), `+ - * / % **` on numbers, unary `+`/`-`, and the bitwise family `& | ^ ~ << >> >>>` — all with JS number semantics (`/` is float division, `%` truncates, `**` is float pow with the exact JS corners — `1 ** NaN` is NaN, `(-1) ** Infinity` is NaN, right-associative `2 ** 3 ** 2` is 512; bitwise and shifts are ToInt32 with the shift count masked & 31, `>>>` yielding the unsigned 32-bit value; unary `+` is the identity on numbers). `**` and `/` results are float-classed; bitwise/shift operands are integer-required positions (a float operand is a taught NS1016).
- Every compound assignment as a statement: `+= -= *= /= %= **= &= |= ^= <<= >>= >>>=`, each exactly `x = x op v`, plus the guarded forms `&&=`/`||=` (boolean targets; the right side evaluates only when assigned, like JS) and `??=` (optional targets; assigns only when null). A number `++`/`--`/assignment may sit in a VALUE position when the split statement is provably order-exact — the variable's only mention in the statement, in a position JS cannot skip (`arr[i++]`, `const n = ++count`, `const z = (y = 5)`; postfix yields the pre-step value, everything else the post-step value, exactly JS); every other value-position form is taught (NS1043 — ternary branches, short-circuit right operands, loop conditions, or a second mention of the variable).
- The Math batch, every corner pinned to node: `Math.min` / `Math.max` (any arity — `Math.min()` is Infinity, `Math.max()` is -Infinity, NaN propagates, -0 orders below +0), `Math.round` (half toward +Infinity), `Math.floor` / `Math.ceil` / `Math.trunc` (NaN/Infinity propagate; the -0 results keep their sign, so `Math.ceil(-0.5)` is -0), `Math.abs` (clears the zero sign), `Math.sign` (NaN stays NaN, a zero keeps its sign), `Math.sqrt` (negative input is NaN, `sqrt(-0)` is -0). `Number.isInteger` / `Number.isFinite` / `Number.isNaN` classify like node, and the `NaN` / `Infinity` globals are ordinary number values. Math calls over compile-time constants fold to their exact JS value — `const HALF = Math.floor(5 / 2)` is the integer 2, `5 % 0` is NaN, `-5 % 5` is -0. A bare `-0` literal (and constant arithmetic folding to -0, like `0 * -1`) is a float value — only f64 carries the signed zero — so it cannot flow into an index or another integer-required slot. The integer rule: floor/ceil/trunc/abs/sign of an integer-classed value stays integer-classed, but of a float value stays float (floor of NaN is NaN), so `bytes[Math.floor(x / 2)]` over a float `x` is still a taught NS1016 — keep index flows integer end to end.
- Template literals with integer holes (`` `${n} of ${total}` ``) feeding `asciiBytes` (below). Float-valued holes are not in v1 (JS float-to-string fidelity is a runtime v2 surface).
- Module-level `const` numbers and strings fold to comptime constants, and const tables emit as rodata (no arena, shared for free at commit): arrays of numbers / booleans / strings / literal-union members (`const WEEKDAYS = [3, 5, 2]`, `const ORDER: readonly Filter[] = ["done", "all"]`), records annotated with an interface (`const LIMITS: Limits = { lo: 1, hi: 9 }`), and arrays of records (`const SEEDS: readonly Task[] = [...]`, names as `asciiBytes` literals). Element access, `.length`, `for...of`, and the array methods all work over tables. Everything inside must fold at compile time — no spreads, no calls except `asciiBytes` on a literal — and a record table needs its interface annotation (an unannotated `{ ... }` is a taught stop naming the fix). Helper functions; recursion.
- **Generics — module-level, monomorphized per call site.** A generic `function`, `interface`, or `type` declares type parameters and instantiates from tsc's RESOLVED type arguments (explicit or inferred): `export function pick<T>(xs: readonly T[], i: number): T { return xs[i]; }` called with tasks emits `pick__Task`, with numbers `pick__f64` (a bare `number` type argument is always f64 — the JS-exact class), one readable Zig fn per distinct instantiation, deduped. Generics over records, unions, arrays, optionals, and bytes all work; generic interfaces/aliases instantiate structurally (`Box<Task>` emits `Box__Task`; `type Opt<T> = T | null` resolves straight through); generics may recurse and call other generics (the inner call resolves at the outer's instantiation). `typeof CONST` type-query aliases resolve through tsc too (`const LIMIT = 9; type Limit = typeof LIMIT`). The boundaries teach: a call site whose type argument stays abstract (`pick([])` infers `never`; `any`/`unknown`, unnamed literal unions) is NS1053 — annotate the call or name the alias; generic function VALUES and generic entry points are NS1050.
- **Data classes — fields, one constructor, plain methods, statics; no inheritance.** `class Task { title: Uint8Array; done: boolean = false; constructor(title: Uint8Array) { this.title = title; } toggle(): void { this.done = !this.done; } isDone(): boolean { return this.done; } }` emits as a plain struct plus module-level functions; `new Task(...)` constructs a record-shaped value (field initializers run in declaration order, then the constructor body). `static` members are per-class module declarations: a `static` method lowers to a receiver-less module fn under the class's mangled name (`Task.fromRow(...)` resolves to `Task__fromRow`), and a `static readonly` field with an initializer is a module const (`Task.LIMIT` — the module-const value rules apply: numbers/strings fold, tables need their annotation); a MUTABLE static is module state and teaches NS1010, and inside a static member reach other statics by the class name, never `this` (NS1056). `private`/`protected` keywords are accepted and ERASED — tsc enforces them at the type level, which is their whole meaning (`#`-fields stay taught: runtime privacy brands). `this` reaches instance fields and methods (`this.count`, `this.step()`) — anything that lets `this` escape as a value (returning it, storing it, passing it) is taught (NS1056), so fluent chaining is out. Mutation follows exactly the array ownership rule: an instance your function creates with `new` mutates freely — direct field writes (`t.count = 1`, `t.count += 2`) and methods that write `this` — until it ESCAPES (returned, passed, stored, aliased — then NS1051), and parameters/model data never mutate (NS1001); methods that only read are callable on anything. Fields require type annotations; instances flow between functions, sit in arrays, and compare/narrow like records. The class TAIL teaches by name: `extends`/`super`/`abstract` (NS1055 — compose, or model variants as a `kind`-union), getters/setters/`#`-privates/`accessor`/class expressions (NS1056), mutable statics (NS1010), generic classes (NS1053), parameter properties (NS1008), `instanceof` (NS1041 — a `kind` field is the tag that exists). Class instances stay LOCAL values in v1: storing one in the Model tree is taught (NS1056) — keep records (interfaces) in the Model and construct the class where behavior is needed.
- **Exceptions — `throw`/`try`/`catch`/`finally` as pure control flow.** Inside a core, exceptions are deterministic: `throw` carries a subset VALUE and unwinds to the nearest enclosing `catch` — across helper calls, out of array-method callbacks (a `throw` inside `.map`'s callback exits the whole loop, like JS), through nested `try`s, with `finally` running on every path (fall-through, `return`, `break`/`continue`, and throw alike). The discipline is two rules. First (NS1057): thrown values are kind-tagged subset shapes — throw kind-discriminated records (`throw { kind: "bad_digit", at: i } as ParseError`, where `ParseError` is an interface with a string-literal `kind` field or a `kind`-discriminated union; a single-shape core may also throw a number), and SEVERAL distinct shapes may throw: the checker collects every shape the core throws into its implicit thrown union. The catch binding IS that union — narrow it in place with kind tests, no `as` ceremony: `catch (e) { if (e.kind === "bad_digit") return -e.at; if (e.kind === "io") return e.code; return -1; }` (or `switch (e.kind)` — tsc cannot prove exhaustiveness over the implicit union, so give the switch a `default` or a trailing return). Bare rethrow (`throw e;`) re-raises the bound value — a narrowed arm included — and `catch { ... }` needs no binding; the single-`as` form (`const err = e as ParseError;`) stays legal in single-shape cores (and for a DECLARED union whose arms equal the thrown set — declare `type AppError = ... | ...` and `as AppError` works). What teaches: untagged values in a heterogeneous set, two shapes sharing one `kind` with different payloads, asserting one member shape of a multi-shape core, the binding escaping untyped into a call/store/return, and `throw new Error(...)` (engine error objects carry stack traces with no native layout). Second (NS1058): `finally` never redirects control flow — no `return`/`throw`/`break`-out inside it (JS's own no-unsafe-finally rule; loops fully inside the finally may break within themselves). An UNCAUGHT throw that reaches an exported function's boundary is a defined deterministic panic — exactly where node's process would crash. A throw mid-mutation of an owned array keeps the mutations applied so far, exactly like JS — the catch sees the array as node would.
- **Local function values — const helpers hoist.** `const scale = (x: number): number => x * 3;` (arrow or `function` expression) hoists to an ordinary module-level fn when it is capture-free (module constants and other const helpers are fine to reference; enclosing locals/params are not — pass them as parameters), fully annotated (every parameter and the return type), and used only by direct calls (`scale(v)`, recursion included) or as an array-method callback (`xs.map(scale)`, comparators included). Everything else teaches NS1054: captures, missing annotations, `let` bindings, returning/storing the value, passing it to your own functions, calling through a record field. Capturing a locally-owned array also ENDS its ownership at the capture (a later mutation is the NS1051 teach) — the stored closure would retain the reference.
Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above): `.toSorted()`/`.sort()` without a comparator (JS ToString ordering; pass `(a, b) => a - b`), `.reduce` without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), `.indexOf`/`.includes` on record arrays (match a field with `.find`/`.findIndex`), `.join` on number arrays (elements are float-valued; join byte values instead), float values (`/`, `**`, `Math.round`, `Math.sqrt`, float `Math.floor`-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, `Number` methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (`readonly View[]`) or arrays of byte-strings (`readonly Uint8Array[]`) as model fields (wrap the element in a single-field interface), record payloads on `Cmd.request` results (results and errors arrive as one bytes payload; the record-shaped results are `Cmd.fetch`'s `{ status, body }` arm, `Cmd.spawn`'s collect `{ code, output }` arm, and the fixed audio event arm), streaming fetch responses (`Cmd.fetch` is buffered only; spawn line streams are the streaming surface), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout line over the engine's 4 KiB line bound arrives cut, without a flag), and non-timer subscriptions (`Sub.timer` is the one subscription; one-shot needs are `Cmd.delay`, and process/audio streams are Cmd-initiated, not subscribed).
## Effects are Cmd data
`update` never performs an effect — it can return one, as inert data, alongside the next model. Import the factories from the SDK and declare the pair-return type:
```ts
import { Cmd } from "@native-sdk/core";
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "add":
return [{ ...model, count: model.count + 1 }, Cmd.persist()];
case "request_time":
return [model, Cmd.now("tick")]; // dispatches { kind: "tick", at: <ms> }
case "tick":
return { ...model, lastTick: msg.at }; // bare model = [model, Cmd.none]
case "ship":
return [model, Cmd.batch([Cmd.persist(), Cmd.host("beep", model.count)])];
}
}
```
The command set (Cmd wire format v2):
- `Cmd.none` — no effects; returning a bare `Model` is sugar for `[model, Cmd.none]`.
- `Cmd.persist()` — ask the host to persist the committed model.
- `Cmd.now("tick")` — request a timestamp; the runtime dispatches the named Msg arm with the time (ms) as its payload. The target arm must carry exactly one number field (`{ kind: "tick", at: number }`), and tsc checks that for you.
- `Cmd.host(name, ...args)` — a fire-and-forget host command by literal name; the host decides what the name means. Args are numbers, OR exactly one bytes payload: a `Uint8Array` (`Cmd.host("clipboard.write", model.draft)`) or a flat inline record of number/boolean/`Uint8Array` fields (`Cmd.host("cfg.save", { gain: model.gain, on: model.muted, label: asciiBytes("main") })`) — the record lowers to one bytes payload from your types at build time, byte-identical under node and native. Anything else (a smuggled string, a nested record, a payload plus extra args) is a taught error (NS1020/NS1026).
- `Cmd.request(name, payload, { key?, ok, err })` — a routed host command: the host performs `name` with the payload (same bytes/record rules) and dispatches exactly one result back to you as an ordinary Msg — the `ok` arm with the result bytes on success, or the `err` arm with the error bytes on failure. Both arms must carry exactly one `Uint8Array` field (`{ kind: "loaded", body: Uint8Array }`), checked by tsc and taught by NS1027. The routing is data — string-literal arm names, never callbacks — so the result decoder derives from your Msg types at build time. The optional `key` (a string literal) names the in-flight effect: issuing a request whose key is already in flight replaces it (the old result is dropped), which is the debounce/exactly-one-in-flight discipline.
- `Cmd.cancel(key)` — drop the in-flight keyed effect with that key, silently: a cancelled request, named engine op (`readFile`/`writeFile`/`fetch`/`clipboardRead`), or armed delay dispatches NEITHER arm — its result is simply dropped. The one exception is a live spawn stream (below): cancel ends the child and the stream's `err` arm dispatches with `cancelled` — killing a process is an observable event, kept loud on purpose.
- `Cmd.batch([a, b])` — several commands from one dispatch, performed in order.
### The named engine ops
These map directly onto the host's effect engine — files, HTTP, the clipboard, one-shot timers. Routing follows the `Cmd.request` rules (inline `{ key?, ok, err }`, string-literal arm names, arm shapes checked by tsc and taught by NS1027), with one difference from `request`: each op's `ok` arm has the op's OWN result shape. Keys follow the one keyed-effect rule everywhere: issuing an op whose key is already in flight REPLACES the old one (the superseded op's result is dropped — no message), and `Cmd.cancel(key)` drops it silently. Every `err` arm carries exactly one `Uint8Array` field and receives a machine-readable reason. Paths, URLs, and bodies are bytes (`asciiBytes` for literals); dynamic values the engine refuses at runtime surface through the `err` arm, while compile-time-knowable bound violations stop the build (NS1030).
- `Cmd.readFile(path, { key?, ok, err })` — read a whole file. `ok` arm: one `Uint8Array` field with the content. `err` reasons: `not_found`, `io_failed`, `truncated` (the file exceeds the engine's 1 MiB read bound — a cut file never passes as whole), `rejected`. Paths are at most 1024 bytes.
- `Cmd.writeFile(path, bytes, { key?, ok, err })` — write a whole file (parent directories created, an existing file replaced whole; at most 1 MiB). `ok` arm: NO payload fields (`{ kind: "wrote" }`) — a successful write has nothing to report. `err` reasons: `io_failed`, `rejected`.
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })` — a buffered HTTP(S) exchange. `ok` arm: exactly two fields, one `number` and one `Uint8Array` (`{ kind: "fetched", status: number, body: Uint8Array }`) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is still `ok` (an HTTP-level error is a delivered response). `err` reasons: `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `rejected`, and `truncated` (the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object: `url` bytes (≤ 2 KiB), `method` one of `"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"` (default GET), `headers` an inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes (`{ authorization: bearerToken(model.apiKey), "content-type": "application/json" }` — how a launch-supplied key rides an `Authorization` header; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030), `body` bytes (≤ 64 KiB), `timeoutMs` a positive integer literal (engine default when omitted). Buffered only — no streaming responses in v1.
- `Cmd.clipboardWrite(bytes)` — put bytes on the system clipboard, fire-and-forget: there is no routing, and a refused or over-bound write is dropped by design.
- `Cmd.clipboardRead({ key?, ok, err })` — read the clipboard. `ok` arm: one `Uint8Array` field with the text. `err` reasons: `failed` (no clipboard service, over-bound content, pasteboard error), `rejected`.
- `Cmd.delay(key, ms, "fired")` — a keyed ONE-SHOT timer: dispatches the named arm once, `ms` from now, with the fire time (ms) as its single number payload (the same arm shape `Cmd.now` and `Sub.timer` target). Re-issuing a live delay key re-arms it from now — that is the debounce discipline (`Cmd.delay("autosave", 800, "save_now")` on every keystroke, one fire after the pause). `Cmd.cancel(key)` drops it silently. The interval is 1ms to one year; a literal outside that stops the build (NS1030).
One honesty note on `Cmd.persist()`: it compiles and encodes, but no shipping host implements the persist verb yet, so the checker teaches NS1028 as a WARNING (never failing the build). Persist real state with `Cmd.writeFile` and load it back with `Cmd.readFile` from `initialModel`'s boot command — the pattern every real app uses.
### The streaming ops: spawn and audio
Two effect families deliver MANY results from one command — a keyed stream the app opens imperatively and drives (this is the opposite of `Sub`: a Sub is declared from the model and the host reconciles it; a stream is a `Cmd` with a lifecycle you cancel or stop). Routing still follows the `Cmd.request` rules: string-literal arm names, shapes checked by tsc and taught by NS1027.
- `Cmd.spawn(argv, { key?, stdin?, line?, exit, err })` — run a subprocess, streaming stdout line by line. `argv` is an inline array literal of bytes elements (`[asciiBytes("/bin/ps"), asciiBytes("-axo")]`, at most 16 elements, 2 KiB total; the array shape is NS1029, the bounds NS1030), and `stdin` (optional bytes, ≤ 4 KiB) is written to the child once. Each stdout line dispatches the `line` arm (one `Uint8Array` field) as it arrives, across dispatches; omit `line` to drop lines (an exit-only spawn, e.g. piping stdin to `pbcopy`). Exactly ONE terminal ends the stream: a clean exit dispatches the `exit` arm — one number field carrying the exit code (a non-zero code is still `exit`: the process ran; its failure code is yours to read) — and every other end dispatches `err` with the reason bytes: `signaled`, `cancelled`, `rejected` (a duplicate live key, or dynamic argv/stdin the engine refused), `spawn_failed` (the binary could not start). Lines over the engine's 4 KiB line bound arrive cut.
- `Cmd.spawn(argv, { key?, stdin?, collect: true, exit, err })` — the same child, whole stdout buffered instead of streamed (the system-monitor shape: run `ps`, parse the block). No `line` arm (NS1027 teaches the conflict). The `exit` arm is a two-field record — one number field (the exit code) and one `Uint8Array` field (the collected stdout, up to 512 KiB), matched by type like `Cmd.fetch`'s arm. Collected stdout over the bound routes `err` with `truncated` — a cut block never parses as whole.
- `Cmd.cancel(key)` aimed at a live spawn ends the child mid-stream; the stream's `err` arm dispatches with `cancelled` — loud on purpose, because killing a process is an observable event (the contrast with the named ops, whose cancel is silent). Spawn keys are the ONE exception to the replace rule: a spawn whose key is already streaming is rejected (`err` gets `rejected`), never replaced — a running subprocess is never killed implicitly; cancel it first.
- `Cmd.audioPlay(key, { path?, url?, cachePath?, expectedBytes? }, { event })` — open the audio event stream. One player is the whole surface, so a new `audioPlay` always REPLACES the current playback (the one key-reuse exception besides `Cmd.request`). The source cascade is the engine's: the local `path` is tried first, a missing file falls through to `url` (streamed progressively, cached at `cachePath` when given, integrity-gated by `expectedBytes` — omitted/0 means unknown size). At least one of `path`/`url` is required (NS1029); each is bytes, at most 1 KiB (NS1030). Prefer OMITTING `cachePath` for URL sources: when the app wiring configures a caches directory (`TsUiApp`'s `audio_cache_dir`), the host derives the conventional content-addressed cache path from the URL itself — your update never builds filesystem paths, and replay re-derives the same path by construction. Pass `cachePath` only to override that convention.
- The `event` arm is the one SDK-fixed record shape, six fields matched by NAME: `state` (the `AudioState` string-literal union — import it from `@native-sdk/core/events`, or declare an alias with exactly the members `"loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum"` in any order; the runtime matches members by name), `positionMs: number`, `durationMs: number` (milliseconds; the duration is the player's estimate), `playing: boolean`, `buffering: boolean` (true while a streamed url is stalled waiting for bytes), and `bands: Uint8Array` (the 32 spectrum band magnitudes, 0255 each, all zeros outside `"spectrum"` events). Every playback event dispatches this arm — `"failed"` (unplayable source, decode/device failure) and `"rejected"` (an empty or over-long source) included, so failure is never silence — until `Cmd.audioStop` closes the stream. `"completed"` fires once at the natural end and does NOT close the stream: starting the next track from it is the idiom.
- `Cmd.audioPause(key)` / `Cmd.audioResume(key)` / `Cmd.audioStop(key)` / `Cmd.audioSeek(key, ms)` / `Cmd.audioSetVolume(key, volume)` — fire-and-forget control verbs: no result of their own; their consequences arrive on the event stream (`audioResume` on a dead player reports one `"failed"` event, never silence). A verb whose key names no open stream is a no-op. `audioStop` is the audio stream's close — no events for the key after it (`Cmd.cancel` does not apply to audio). Volume is clamped 0..1 and remembered across tracks; a literal outside 0..1 (or a negative seek literal) stops the build (NS1030).
Commands are constructed inline in the return path and nowhere else (NS1017): never in the Model or a Msg, never in a local, never in a helper. This is what keeps effects inside the dispatch cycle and replay honest.
### The init command
`initialModel` may return the same pair to run a boot effect once at install, before the first view build — loading a store is the canonical use:
```ts
export function initialModel(): [Model, Cmd<Msg>] {
return [
{ notes: [], loading: true },
Cmd.request("store.read", asciiBytes("notes.bin"), { key: "boot", ok: "loaded", err: "load_failed" }),
];
}
```
A plain `initialModel(): Model` stays exactly as before; the pair is opt-in.
### Subscriptions are Sub data
Recurring effects are declared, not issued: export `subscriptions(model): Sub<Msg>` and return descriptors derived from the current model. After every commit the host reconciles the returned set against its active timers by key — a new key (or a changed interval) arms a timer, a missing key cancels it — so starting, stopping, and re-tuning timers is just returning different data:
```ts
import { Sub } from "@native-sdk/core";
export function subscriptions(model: Model): Sub<Msg> {
if (!model.running) return Sub.none;
return Sub.batch([
Sub.timer("tick", model.fast ? 250 : 1000, "tick"), // dispatches { kind: "tick", at: <ms> } every interval
Sub.timer("autosave", 30000, "save_now"),
]);
}
```
- `Sub.none` — no subscriptions (everything paused).
- `Sub.timer(key, everyMs, "tick")` — a repeating timer named by its string-literal key; each fire dispatches the named arm with the current time (ms) as its single number payload (the same arm shape `Cmd.now` targets). The interval may derive from the model.
- `Sub.batch([...])` — several at once.
Sub values follow the Cmd purity rule with their own home (NS1025): built inline in `subscriptions`' return path, never stored, never returned from anywhere else. Debounced re-arm falls out of reconciliation — change the key or interval and the timer re-arms; drop it from the set and it stops.
Keep the Sub-vs-stream line straight: a Sub is DECLARATIVE — derived from the model, started and stopped by reconciliation, and the app never opens or closes one. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events) are Cmd-INITIATED — imperative opens with a keyed lifecycle the app drives (`Cmd.cancel` for spawn, `Cmd.audioStop` for audio). If the effect should exist exactly while some model state holds, it wants a Sub shape; if the app decides when it starts and ends, it is a stream.
One caveat for node-side pokes: the transpiler resolves the `@native-sdk/core*` specifiers for you, but plain `node` does not know them, so quick behavioral checks under node work directly on cores with no SDK import, and on cores importing `Cmd`, `Sub`, `asciiBytes`, or the text engine only with a module mapping (or by copying the SDK module files next to the core and rewriting the specifiers). `native dev --core` already maps them.
## Splitting a core into modules
`src/core.ts` is the ENTRY module; a core that outgrows it splits into more `.ts` files under `src/` (subdirectories included). The whole import graph still emits as ONE native module - one rt kernel, one flat namespace, a section per source file - and runs unchanged under node.
- **Spell relative imports with the real filename**: `import { parsePs } from "./parsers.ts"` (node's loader resolves real files, not bare stems - a missing extension or a missing file is a taught NS1037).
- **`src/` is the boundary**: `../` escapes and absolute paths are taught (NS1034); bare npm specifiers are taught (NS1035 - vendor the code under `src/` or make the import `import type`). Only `@native-sdk/core` (the intrinsic Cmd/Sub/asciiBytes surface) and the SDK library modules below carry runtime meaning from outside.
- **Everything module-level is importable**: interfaces, literal-union aliases, discriminated unions, module `const` numbers and tables, and helper functions all cross files (renamed imports and `import * as ns` namespace aliases both work — the alias is dot-syntax over the same flat namespace, never a value of its own). Export lists and value re-exports work too: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name (a renamed binding emits as a flat-namespace alias). Type names and EXPORTED value names must be unique across the core's files (NS1038 - declare once, import where used; renamed exports claim their new names in the same namespace); colliding PRIVATE helpers are fine (the emitter uniques them with a per-module prefix).
- **No runtime import cycles** (NS1036). `import type` back-edges are legal and idiomatic: a helper module type-imports `Model` from `./core.ts` while `core.ts` runtime-imports the helpers - that is the expected shape, not a smell.
- **The entry contract (NS1014)**: `update`, `initialModel`, `subscriptions`, the wiring channels (`commandMsg`/`keyMsg`/`frameMsg`/`appearanceMsg`/`chromeMsg`/`envMsgs`), and `viewUnbound` are DECLARED in `core.ts` and exported under their own names (`export` on the declaration or an un-renamed `export { update }` list entry — a rename or a re-export from an imported module cannot bind an entry point) - imports may FEED them, never replace them. The markup binding surface is also entry-only: an exported single-Model-parameter helper binds (`{doneCount}`) only when it is DECLARED in `core.ts` — export lists participate under their exported names (`export { taskTotal as taskCount }` binds `{taskCount}`), but a re-export of an imported helper does not bind (under node the app's module object is the entry's exports, so it would bind natively but not exist under node). Imported modules export cross-module API for update and the entry helpers to call.
- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never emitted) these are ordinary subset TypeScript, transpiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name.
The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player.ts + the SDK text engine), `examples/system-monitor-ts` (core.ts + parsers.ts + table.ts + the SDK text engine), and `examples/ai-chat-ts` (core.ts + api.ts — the JSON-over-bytes wire-format reference: request encoding and a targeted parse walk that returns `null` on anything malformed) in the SDK repo.
## Text is bytes
`string` in a core is for literals, string-literal-union tags, and `===` comparisons — content equality, on tags and plain `string` values alike (`name === "app.add"` in a command mapper works and behaves identically under node and native). Dynamic, user-visible text lives in the Model as `Uint8Array` — indexing yields byte values, `.length` is byte length, `subarray` is a view and `slice` is a copy, and both resolve their bounds the JS way (negatives count from the end, out-of-range clamps, a crossed range is empty), identical under node and native. Observing a `string`'s code units (`.length`, `s[i]`, `.charCodeAt`) is banned (NS1004) because UTF-16 and UTF-8 would disagree, and `+` concatenation is banned (NS1018) because runtime string building needs a JS string heap the binary does not carry — build text with template literals into bytes instead.
Turn literals and templates into bytes with the `asciiBytes` intrinsic from the SDK. The transpiler recognizes the import by identity and folds every call at compile time — a literal argument becomes rodata, a template becomes per-dispatch arena bytes — and under node the same import runs as a plain function with the same result:
```ts
import { asciiBytes } from "@native-sdk/core";
export type Bytes = Uint8Array;
const label = asciiBytes(`${done} of ${total} done`); // arena bytes
const seed = asciiBytes("Stretch"); // rodata, free to commit
```
Arguments must be string literals or templates (the fold happens at compile time); dynamic text is already bytes, so there is nothing to bridge. Hand-rolling the old bridge shape (`function asciiBytes(s: string): Bytes { ... }`) no longer gets special treatment — its body observes code units and teaches NS1004.
### The byte-text string methods
Bytes read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2 and `padStart` pads by bytes), search is byte-wise, and case mapping is Unicode SIMPLE case mapping (code point → code point from the Unicode tables; locale-free, no special casing — `ß` stays `ß`, `σ` uppercases to `Σ`; bytes that are not well-formed UTF-8 pass through case mapping unchanged). Natively each call lowers onto an rt kernel helper; under node the devhost installs the same methods from the same generated tables, so both runtimes produce identical bytes by construction.
```ts
const query = model.query.trim().toLowerCase(); // JS whitespace set; simple case map
if (title.toLowerCase().includes(query)) { ... } // byte substring search
const bar = asciiBytes("#").repeat(used).padEnd(w, asciiBytes(".")); // w is a BYTE width
const cells = row.split(asciiBytes(",")); // Uint8Array[] — the array is yours (push works)
const ext = name.lastIndexOf(asciiBytes(".")); // byte offset, -1 when absent
const last = line.at(-1) ?? 0; // byte value | undefined (the .find one-empty)
```
- `toUpperCase()` / `toLowerCase()` — fresh bytes, simple case mapping only (locale casing stays out; `toLocaleUpperCase` teaches NS1005).
- `repeat(n)``repeat(0)` is empty; the count is an integer position (a fractional flow is NS1016), and a NEGATIVE literal stops the build — JS throws RangeError there, so a dynamic negative count is the same crash on both runtimes (node throws, native panics): guard `n >= 0` first.
- `startsWith(b)` / `endsWith(b)` — bytes needles; the empty needle is true, exactly String's.
- `includes(x)` / `indexOf(x)` / `lastIndexOf(x)`**dispatch by argument type**: a BYTES argument is substring search with String's shapes (`indexOf(empty)` is 0, `lastIndexOf(empty)` is the byte length, offsets are byte offsets), a NUMBER argument keeps the TypedArray element search node already ships (one byte value, SameValueZero — `includes(66.5)` is false, `-0` matches 0); no fromIndex/position arguments in v1.
- `padStart(n, fill?)` / `padEnd(n, fill?)``n` is the target BYTE length, default fill `" "`; the last fill repetition truncates BY BYTES (a multi-byte fill can cut mid-sequence — identically on both runtimes); at-or-under target returns the receiver unchanged.
- `trim()` / `trimStart()` / `trimEnd()` — strip the JS whitespace set (tab/LF/CR family, NBSP, the Unicode spaces, U+FEFF) decoded over UTF-8; views, no copy. Invalid UTF-8 is not whitespace — trimming stops there.
- `split(sep)` — bytes separator, `Uint8Array[]` out with String.split's shapes (leading/trailing/adjacent separators produce empty elements; no match is `[whole]`); elements are views, the ARRAY is locally owned (push/sort it before it escapes); an empty separator literal is a taught stop (per-code-point splitting would expose the encoding seam), and no limit argument in v1.
- `at(i)` — byte access: the byte value at a byte index (negatives from the end) or `undefined` out of range — fold with `??` or test `=== undefined`.
The stays-out tail teaches by name (NS1060 unless noted): `charCodeAt`/`charAt`/`codePointAt` read UTF-16 code units bytes do not have (read `b[i]`/`.at(i)`, slice byte ranges), `normalize` is a host-edge concern, `replace`/`replaceAll` are named deferrals (rebuild with `split`/`indexOf` + a push-builder of parts), `localeCompare`/`toLocaleUpperCase`/`toLocaleLowerCase` read the ambient locale (NS1005), and `match`/`matchAll`/`search` take regexes (NS1040).
The SDK text helpers remain for what the methods do not cover: `containsIgnoreCase` (ASCII case-insensitive search) and `orderIgnoreCase` (an ASCII case-insensitive `.toSorted` comparator) from `@native-sdk/core/text`. `trimAsciiSpaces` also stays, but it is NOT `.trim()`: it strips space/tab/CR only — never LF — as a no-copy view, which is exactly right for line-oriented parsers where `\n` is the record separator; for user input and general whitespace, `.trim()` is now the canonical form.
A freshly created `Uint8Array` is writable until it escapes (stored, returned, passed on); after that it is immutable like everything else. One consequence worth knowing before you build byte output: a buffer PASSED to a helper has escaped, so a writing helper cannot take an out-parameter — build bytes with measure-then-fill inside one function (count the output length, allocate exactly it, write inline), or build a `Uint8Array[]` push-builder of parts and concatenate once (`examples/ai-chat-ts/src/api.ts` does both, JSON escaping included). `===` on two `Uint8Array` values is banned (it would be JS reference identity, which has no native mapping) — compare an id field, or contents with a loop (`s.startsWith(t) && s.length === t.length` is the whole-equality idiom). `bytes.join("-")` renders byte values as decimal text with a literal separator (arena bytes, same result under node); number arrays have no `.join` because their elements are float-valued.
## Text input from markup
A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) needs two things from the core: a bytes field the control renders (`draft`), and a Msg arm carrying the text-input event. The event union mirrors the runtime's event vocabulary structurally (markup's `on-input` matches the shape and translates each runtime event into your union at dispatch). Import it — `import { type TextInputEvent } from "@native-sdk/core/text"` (also re-exported by `@native-sdk/core/events`) — or copy this declaration verbatim into the core:
```ts
export type TextCaretDirection = "previous" | "next" | "previous_word" | "next_word" | "start" | "end";
export interface TextCaretMove { readonly direction: TextCaretDirection; readonly extend: boolean; }
export interface TextSelection { readonly anchor: number; readonly focus: number; }
export type TextInputEvent =
| { readonly kind: "insert_text"; readonly text: Uint8Array }
| { readonly kind: "delete_backward" }
| { readonly kind: "delete_forward" }
| { readonly kind: "delete_word_backward" }
| { readonly kind: "delete_word_forward" }
| { readonly kind: "clear" }
| { readonly kind: "move_caret"; readonly move: TextCaretMove }
| { readonly kind: "set_selection"; readonly selection: TextSelection }
| { readonly kind: "set_composition"; readonly text: Uint8Array; readonly cursor: number | null }
| { readonly kind: "commit_composition" }
| { readonly kind: "cancel_composition" };
export type Msg = /* ... */ | { readonly kind: "draft_edit"; readonly edit: TextInputEvent };
```
Your `update` reduces the events over the draft bytes. A minimal reducer (append / backspace / clear, caret events ignored) covers simple fields; full caret/selection/composition fidelity is ONE IMPORT — the SDK ships the complete byte-splice engine as `@native-sdk/core/text` (`applyTextInputEvent` + `clampedInsertEvent`; see "Splitting a core into modules"), so no core hand-rolls it anymore. Stacked case labels share a body (`case "move_caret": case "set_selection": return draft;`) as long as the shared body reads no payload.
## Continuous controls from markup
Sliders and scroll regions stay model-driven (a committed model has no `sync` hook): bind the widget's value and receive the applied value back as a Msg.
- **Slider (`on-change`, the value-payload change event):** a slider's `on-change` with a bare tag naming a ONE-NUMBER FLOAT arm dispatches the applied 0..1 fraction into it (`<slider value="{progressFraction}" on-change="scrubbed" label="Seek" />` with `{ kind: "scrubbed"; fraction: number }` — the fraction must be float-classed; an integer arm is refused because the value is a clamped fraction). A VOID arm keeps the older static form ("something changed", no value). The same one-number float arm carries a split's `on-resize` fraction.
- **Scroll (`on-scroll`, the ScrollState mirror):** name an arm carrying the scroll-state record — matched by field name, the TextInputEvent rule. Import the record (or declare the same shape in-file). Echo `offset` into a model field bound as the scroll's `value`, and setting that field in update scrolls the region (page changes reset to top):
```ts
import { type ScrollState } from "@native-sdk/core/events";
// (or declare it yourself: { offset, velocity, viewportExtent, contentExtent }, all numbers)
export type Msg = /* ... */ | { readonly kind: "library_scrolled"; readonly scroll: ScrollState };
```
```
<scroll value="{libraryScrollTop}" on-scroll="library_scrolled"> ... </scroll>
```
## The host-event wiring channels
The generated wiring detects each channel from an export (export exists → wired; a wrong shape is a taught NS1033). Event records are matched by field name, STRUCTURALLY: import them from `@native-sdk/core/events` (`FrameEvent`, `KeyEvent`, `ColorScheme`, `ChromeInsets`/`ChromeButtons`) or declare the same shapes in your core — both emit as your module's types and match identically. The `appearanceMsg`/`chromeMsg` arms themselves stay inline union members (`kind` plus the event's fields — the subset has no intersection arms); the SDK's `AppearanceEvent`/`ChromeEvent` records are those arms' canonical payload shapes, importable for helper signatures.
- **`commandMsg(name: string): Msg | null`** — menus, shortcuts, and chrome tabs, by command id (string equality works on `string` values).
- **`frameMsg(model: Model, frame: FrameEvent): Msg | null`** — presented frames. `FrameEvent` is exactly `{ width, height, timestampMs, intervalMs }` numbers (canvas points; fractional milliseconds). Return null for frames that change nothing — the idle law holds exactly when an idle app dispatches nothing (a frame arm that always returns a Msg would spin the loop at full frame rate). The installing frame is excluded; the first PRESENTED frame corrects any seeded value.
- **`keyMsg(key: KeyEvent): Msg | null`** — the app-level key FALLBACK (a focused widget's own keys and editable text always win first). `KeyEvent` is exactly `{ key: string; shift: boolean; control: boolean; alt: boolean; super: boolean }`; the key NAME arrives lowercased (`key.key === "space"`).
- **`export const appearanceMsg = "<arm>"`** — system appearance changes dispatch the named arm, a record of exactly `{ colorScheme: ColorScheme; reduceMotion: boolean; highContrast: boolean }` where `ColorScheme` is a NAMED `"light" | "dark"` alias (routing is data: a string-literal arm name, the effects rule applied to the app shell).
- **`export const chromeMsg = "<arm>"`** — window-chrome geometry (the hidden-titlebar channel), a record of exactly `{ insets: { top; right; bottom; left }; buttons: { x; y; width; height }; tabsProjected: boolean }` (numbers/boolean). Delivered before the first view build and again on changes.
- **`export const envMsgs = [{ env: "NAME", msg: "<arm>" }] as const`** — each named environment variable present at launch dispatches its value through the arm (exactly one `Uint8Array` field, `BytesKind` — annotate with `readonly EnvMsg<Msg>[]` from the SDK for tsc checking) as one journaled Msg right after the boot command. The core never reads the environment (NS1005), and neither does replay: the deliveries are journaled at record time and fed back from the journal, so a recording replays byte-identically even when the variables are unset or changed at replay launch.
- **`app.zon .assets.images`** — `.assets = .{ .images = .{ .{ .id = 1, .path = "assets/art/cover.jpg" }, ... } }`: each file is read once at launch and registered on the installing frame; the `id` is the `ImageId` markup avatar bindings reference (`<avatar image="{coverId}">`), and a missing file or failed decode keeps the initials fallback.
## The rules the checker enforces
Every diagnostic carries one of these IDs plus the fix and the why. Write to them up front:
- **NS1001 shared data is immutable; your own scratch is yours.** Never mutate `model`, `msg`, parameters, module tables, or anything reached from them — build the next value (`{ ...model, tasks: [...model.tasks, task] }`) or copy first (`const copy = xs.slice()`). Arrays this function creates itself mutate freely until they escape — appends via `xs[xs.length] = v`, all-owning reassigned bindings, and readonly-reader borrows included (see "Local mutation" above). The previous model stays live for rendering and undo; unchanged parts are shared, not copied.
- **NS1002 updates are synchronous.** No `async`/`await`/Promises in a core. Work that takes time is command data; the runtime dispatches your `Msg` with the result.
- **NS1003 models hold data, not functions.** No function-typed fields in the Model/Msg tree. Name the behavior as a message and handle it in `update`.
- **NS1004 text is not indexable.** No `.length`/`s[i]`/`.charCodeAt` on `string`. Bytes: `Uint8Array`; literals and templates become bytes through the `asciiBytes` intrinsic.
- **NS1005 update is deterministic.** No `Date.now()`, `Math.random()`, or ambient IO. Take time and randomness as message payloads.
- **NS1006 classes are data classes, declared at module level.** `class` with annotated fields, one constructor, and plain methods compiles (see "Data classes" above); class expressions, `this` outside a member body, and `new` of anything but a data class (or `Uint8Array`) are taught.
- **NS1007 implicit builtin throws stay out.** A JS builtin that throws mid-operation (`.reduce` with no initial value on a possibly-empty array) is taught toward its explicit form; your own `throw` of a subset value is supported control flow (see "Exceptions" above).
- **NS1008 only erasable syntax.** No `enum`, `namespace`, decorators, parameter properties. String-literal unions are the enum.
- **NS1009 no `for`/`in`.** Fixed shapes, no prototypes; model the data as an array and walk it with a classic loop.
- **NS1010 module state lives in the Model.** Module-level `let` is banned (`const` is fine), and a class's MUTABLE `static` field is the same thing by another spelling — `static readonly` consts compile.
- **NS1011 no Map/Set in v1.** Model the data as an id-keyed array of records and look up with a loop or `.filter`.
- **NS1012 object shapes are fixed.** No `delete`, getters/setters, sparse arrays, `Proxy`, `Symbol`. Optional data is `T | null`.
- **NS1013 closed world.** No `eval`, `new Function`, dynamic `import()`.
- **NS1014 the core's entry points live in core.ts.** `update`, `initialModel`, `subscriptions`, the wiring channel exports, and `viewUnbound` belong to the entry module; an entry export in an imported file would be silently ignored by the build, so it is taught instead. Move the export into `src/core.ts` and let the imported module hold what it calls.
- **NS1015 exhaustive switch on message unions.** Cover every `kind`; no `default`.
- **NS1016 integer and fractional numbers never share a slot.** A `number` that indexes memory (or otherwise must be an integer) cannot also receive fractional values; split the field or keep the whole flow integer. In practice this splits a core's numbers into two domains that never meet in one expression: integer values (indexes, ids, counts, anything compared against integer literals or formatted into template holes) and float values (division results, fractional literals). Deriving a float from an integer domain is done by parallel accumulation, not conversion — count in the integer domain and accumulate the float alongside (`pct += 1` next to `fraction += 0.01`); there is no float-to-integer conversion in v1 (`|0` demands an already-integer operand, and `Math.floor` of a float stays float), so integer division is spelled as a bounded whole-unit loop (`while (rest >= 1000) { rest -= 1000; seconds += 1; }`) — and when the dividend is unbounded (wall-clock milliseconds, byte totals), as binary long division, still integer end to end: `function intDiv(n, d) { let q = 0; let r = n; while (r >= d) { let step = d; let count = 1; while (step + step <= r) { step += step; count += count; } r -= step; q += count; } return q; }` (~2·log2(n/d) iterations; `%` of two integer-classed values stays integer, so `dayMs = at % 86400000` needs no loop at all). Primitive-array elements (`readonly number[]`) are always float-classed — wrap ids in single-field records when they must stay integers. One channel corollary: a shared helper parameter is ONE slot, so a helper that receives fractional values in one call cannot receive a proven-integer value in another (push the integer through its own inline site instead — the system-monitor port's process-count history does exactly this beside its fractional cpu/mem pushes).
- **NS1017 commands are issued in update's return, not stored.** Build `Cmd` values inline in the returned `[model, cmd]` tuple; a command in the model, a message, a local, or a helper escapes the dispatch cycle.
- **NS1018 no string concatenation.** `+` (or `+=`) with a string operand builds text at runtime; build bytes with `asciiBytes` on a template literal, or stitch `Uint8Array` buffers with `.set`.
- **NS1019 functions have fixed arity.** No parameter defaults (`= value`), rest parameters (`...xs`), `arguments`, or call spreads (`f(...xs)`); pass every argument explicitly (take and pass an array instead of a spread).
- **NS1020 host command args are numbers or one bytes payload.** `Cmd.host` encodes f64 scalars or one bytes/record payload; a string smuggled through `as` has no encoding.
- **NS1021 optional chains end in `??` or a value use.** `x?.y === null` would have to distinguish JS `undefined` (the chain short-circuited) from `null` (the field's value); normalize with `??` or guard the base first.
- **NS1022 shared arrays sort by copy, not in place.** `.sort()` on a parameter or model array would mutate data that stays live for rendering and undo; sort a copy you own — `const copy = xs.slice(); copy.sort((a, b) => a - b);` — or inline with `.toSorted((a, b) => a - b)`.
- **NS1023 sort comparators return a sign, not a boolean.** JS reads the comparator numerically: `true` coerces to 1 but `false` to 0, which claims equality and leaves pairs unsorted under node too. Return `a - b`, or explicit -1/0/1 branches.
- **NS1024 model text is bytes.** A `string`-typed field anywhere in the Model tree has no commit representation (it would need a JS string heap). Type it `Uint8Array` and build values with `asciiBytes`, or use a string-literal union when the field holds one of a closed set of tags. Reported at the field's declaration.
- **NS1025 subscriptions are declared in subscriptions' return, not stored.** `Sub` values are inert descriptors the host reconciles after every commit; build them inline in `subscriptions(model)`'s return path — a Sub in the model, a message, a local, or another function escapes reconciliation.
- **NS1026 host payloads are bytes or a flat scalar record.** `Cmd.host`/`Cmd.request` carry exactly one payload: a `Uint8Array`, or an inline record of number/boolean/`Uint8Array` fields. Nested records, other field types, or a payload mixed with extra arguments have no wire encoding.
- **NS1027 effect results route to Msg arms by name.** Routing (`{ key?, ok, err }`) and timer targets are string-literal arm names with the payload shape the effect produces — one `Uint8Array` field for host results/errors, one number field for timer and delay fires, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s ok. Callbacks and computed names cannot work: the runtime builds the result Msg from the arm's declared shape at build time.
- **NS1028 Cmd.persist is not yet host-backed (warning).** The op compiles and stays on the wire, but no shipping host performs it — persist with `Cmd.writeFile` and boot-load with `Cmd.readFile` instead. The only non-fatal notice in the set.
- **NS1029 effect op arguments have a fixed shape.** Paths/URLs/bodies are bytes, `Cmd.fetch`'s spec is an inline object with a closed verb literal, a number-literal timeout, and an inline flat record of headers whose NAMES are compile-time ASCII and whose VALUES are string literals or runtime bytes (`Uint8Array`). The record's shape encodes at build time; a runtime header value rides its length-prefixed wire field at dispatch time exactly like `url`/`body` — but a smuggled string (a ternary of literals, a template) has no encoding: make it bytes.
- **NS1031 exported model helpers join the model's binding surface.** An exported single-Model-parameter helper emits as a Model declaration markup binds (`doneCount``{doneCount}`); two members with one emitted name would be ambiguous — rename one.
- **NS1032 viewUnbound names update-only model state.** `export const viewUnbound = [...] as const` entries must be string literals naming Model fields, exported model helpers, or Msg kinds — by their TypeScript spellings (`"nextId"`, not the emitted `"next_id"`); anything else would silence nothing and hide a typo.
- **NS1030 effect arguments respect the engine's limits.** A compile-time-knowable value outside an engine bound (a path literal over 1024 bytes, a URL literal over 2 KiB, more than 8 headers, a header block over 1 KiB, a delay literal outside 1ms..one year) stops the build instead of shipping a guaranteed runtime rejection. Dynamic values stay the engine's to validate — they surface through the `err` arm.
- **NS1033 wiring channel exports match their host event shapes.** `frameMsg`/`keyMsg` take their exact event records and return `Msg | null`; `appearanceMsg`/`chromeMsg` are string literals naming arms with those channels' record shapes; `envMsgs` entries carry `env` and a one-`Uint8Array`-field `msg` arm. The generated wiring builds these host events structurally from your declarations, so a wrong shape is taught here instead of surfacing as a Zig error inside generated code.
- **NS1034 core imports stay inside src/.** `../` escapes and absolute paths are rejected: the entry module's directory is the core's whole world - the build ships exactly that tree.
- **NS1035 npm packages do not run inside a core.** No JS engine ships in the binary; vendor the logic under `src/` or make the import type-only.
- **NS1036 core modules do not import in a cycle.** Runtime cycles only work through JS live-binding indirection; hoist shared declarations, or make the back-edge `import type` (which is exempt and idiomatic).
- **NS1037 an import names a real module file.** Spell relative specifiers with the `.ts` extension and point them at existing files; `@native-sdk/...` specifiers must name a shipped SDK module.
- **NS1038 module-scope names are unique across a core's files.** Type names and exported value names share the emitted module's one namespace - declare the shared thing once and import it (private helper collisions are auto-prefixed instead). Same-file homonyms count too: a type and an exported value cannot share a name, and interfaces never merge.
- **NS1039 a namespace import is a compile-time alias.** `import * as ns from "./util.ts"` works as dot-syntax (`ns.helper(x)`, `ns.Cfg`); `ns` itself is not a value (never stored or passed), and the intrinsic `@native-sdk/core` module is imported by name so the purity rules can see `Cmd`/`Sub`/`asciiBytes`.
- **NS1040 no regular expressions.** A regex is a runtime engine the binary does not carry; scan bytes with loops or the SDK text helpers (`containsIgnoreCase`, `trimAsciiSpaces`).
- **NS1041 types are static: no runtime type or shape tests.** `typeof` values, `in`, `instanceof`, and the `Object`/`Reflect`/`JSON`/`Array` statics read runtime tags fixed native layouts do not have; model alternatives as a discriminated union and switch on its `kind`.
- **NS1042 no generators.** `function*`/`yield` is a resumable frame with hidden state; build the sequence as an array (push-builder or `.map`/`.filter`) and return it whole.
- **NS1043 statements stay statements.** Comma sequences (outside a for-loop incrementor) and `void` squeeze statements into expression position (the subset's empty value is spelled `null`); a number `++`/`--`/assignment in value position is legal only where the split statement is order-exact (sole mention, unskippable — `arr[i++]`, `const n = ++count`), and the taught remainder names why.
- **NS1044 no BigInt or Symbol.** A core's numbers are IEEE f64 slots (integer-classed slots emit i64); model identities as number ids.
- **NS1045 destructuring binds record fields into const locals.** `const { total, done: doneCount } = stats;` is a compile-time alias; array positions, parameter patterns, defaults, rest, and nesting can be silently absent in JS and are taught toward explicit reads.
- **NS1046 functions live at module level.** Nested function declarations, non-const function values, and `?.()` treat functions as runtime values closing over the frame; move the function to module scope, bind it as a const local helper, or pass what it used (inline arrows as call arguments stay).
- **NS1047 modules export their declarations by name.** Named exports all compile: `export` on the declaration, export lists (`export { a, b as c }` — a rename binds a new flat-namespace name), and named value re-exports (`export { x } from "./m.ts"`). What stays out is the unnamed tail: `export default`, `export =`, `export * from`, plus bindings with no single emitted value (renamed generics/classes, wiring config, re-exports of the SDK surface).
- **NS1048 equality is strict.** `==`/`!=` apply JS's coercion table; use `===`/`!==`.
- **NS1049 locals declare with const and let.** `var` hoists to function scope and reads `undefined` before its line — behavior the emitted locals cannot have.
- **NS1050 generics live on module-level declarations.** Module-level generic functions/interfaces/aliases monomorphize per concrete use; entry points and function values stay concrete.
- **NS1051 a local array is yours until it escapes.** Mutating an owned array AFTER it was returned from a callback, stored into a structure, aliased, or passed where the callee could keep or mutate it is taught with the escape named (kind and line): JS would show the holder your later mutations through the shared reference, while the native value was shared structurally at the escape. Finish mutating first, pass the array after the last mutation, or mutate inside the callee — a pass into a `readonly T[]` reader parameter is a borrow, not an escape. An escape inside a loop gates the whole loop body (the second iteration would mutate after the first iteration's escape).
- **NS1052 spread array locals declare their array type.** `const turns = [...model.turns, next];` has no slice target to lower against — annotate the local: `const turns: readonly Turn[] = [...model.turns, next];`.
- **NS1053 generics instantiate per concrete call site.** A generic call whose resolved type argument has no concrete emitted type (`never` from `pick([])`, `any`/`unknown`, an unnamed literal union) is taught — annotate the call site or name the alias.
- **NS1054 function values stay local helpers.** A const-bound, capture-free, fully-annotated function value hoists to a module-level fn; captures, missing annotations, reassignment, storing/returning the value, and record-field calls are taught toward the helper shape.
- **NS1055 classes hold data, not hierarchies.** No `extends`/`super`/`abstract`: compose (a field holding the other record or class), or model the variants as a `kind`-discriminated union and switch on it — emitted classes are flat structs with static dispatch.
- **NS1056 class members are annotated fields, one constructor, and plain methods.** `static` methods, `static readonly` consts, and erased `private`/`protected` compile; getters/setters, `#`-privates, `accessor`, unannotated or optional (`?`) instance fields, `this` escaping as a value (or used inside a static member — statics go by the class name), and class instances stored in the Model tree are each taught toward the data-class shape.
- **NS1057 thrown values are kind-tagged subset shapes.** Several distinct shapes may throw — the checker collects them into the core's thrown union, and `catch (e)` narrows it with kind tests (`if (e.kind === "parse")`), no `as` needed (single-shape cores may still narrow once with `const err = e as YourError;`; bare rethrow always works). What teaches: untagged thrown values in a heterogeneous set, two shapes sharing a `kind` with different payloads, asserting one member of a multi-shape core, the binding escaping untyped, and `throw new Error(...)`.
- **NS1058 finally never redirects control flow.** `return`/`throw`/`break`-out/`continue`-out inside `finally` would override the pending return or exception (JS's no-unsafe-finally); keep `finally` to cleanup statements.
- **NS1059 arrays build from literals, spreads, and loops.** `Array.of(a, b)` is the literal `[a, b]`, `Array.from(xs)` is the spread copy `[...xs]`, and `Array.from({ length: n }, f)` is a classic loop pushing `f(i)` — the statics consume iterables and array-likes, runtime protocols the fixed layouts do not carry (`Array.isArray` stays NS1041: it is a runtime type test).
- **NS1060 byte text speaks the byte-honest method set.** The everyday string methods work on bytes (see "Text is bytes"); the spellings that would reintroduce the encoding seam teach instead — `charCodeAt`/`charAt`/`codePointAt` (UTF-16 code units; read `b[i]`/`.at(i)`), `normalize` (host-edge normalization), `replace`/`replaceAll` (deferred; rebuild with `.split`/`.indexOf` + a push-builder). The locale family teaches NS1005 and the regex-taking methods NS1040.
## Canonical example
A complete core in the idiom — readonly interfaces, a tagged Msg, spread updates, map/filter, the bytes intrinsic, derived exports:
```ts
import { asciiBytes } from "@native-sdk/core";
export type Bytes = Uint8Array;
export type Filter = "all" | "active" | "done";
export interface Task {
readonly id: number;
readonly title: Bytes;
readonly done: boolean;
}
export interface Model {
readonly tasks: readonly Task[];
readonly nextId: number;
readonly filter: Filter;
readonly draft: Bytes;
}
export type Msg =
| { readonly kind: "add" }
| { readonly kind: "toggle"; readonly id: number }
| { readonly kind: "set_filter"; readonly filter: Filter }
| { readonly kind: "draft_edit"; readonly text: Bytes };
export function initialModel(): Model {
return {
tasks: [{ id: 1, title: asciiBytes("Ship the core"), done: false }],
nextId: 2,
filter: "all",
draft: new Uint8Array(0),
};
}
export function visibleTasks(model: Model): readonly Task[] {
if (model.filter === "active") return model.tasks.filter((t) => !t.done);
if (model.filter === "done") return model.tasks.filter((t) => t.done);
return model.tasks;
}
export function doneCount(model: Model): number {
return model.tasks.filter((t) => t.done).length;
}
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "add": {
const title = model.draft.trim();
if (title.length === 0) return model;
const task: Task = { id: model.nextId, title: title, done: false };
return {
...model,
tasks: [...model.tasks, task],
nextId: model.nextId + 1,
draft: new Uint8Array(0),
};
}
case "toggle":
return {
...model,
tasks: model.tasks.map((t) => (t.id === msg.id ? { ...t, done: !t.done } : t)),
};
case "set_filter":
return { ...model, filter: msg.filter };
case "draft_edit":
return { ...model, draft: msg.text };
}
}
```
## Running a core as an app
A `native init` app needs NONE of this section: the build detects `src/core.ts` and stages the wiring itself (a core exporting `commandMsg(name: string): Msg | null` automatically receives menu/shortcut command events as Msgs). The section below is for hand-Zig wiring — embedding a core in an existing Zig app or customizing the UiApp surface.
The emitted core runs as a full desktop app through `native_sdk.TsUiApp(core)` — the committed TS model IS the app model, no shim, no glue:
```zig
const core = @import("core.zig"); // the emitted module (+ its rt.zig)
const Adapter = native_sdk.TsUiApp(core);
const App = Adapter.App; // a native_sdk.UiApp(core.Model, core.Msg)
const BoardView = canvas.CompiledMarkupView(core.Model, core.Msg, @embedFile("board.native"));
var app = Adapter.init(allocator, .{ .audio_cache_dir = resolved_cache_dir }, .{
.name = "board",
.scene = scene,
.canvas_label = "canvas",
.view = BoardView.build, // or any Zig builder view over *const core.Model
.on_command = commandMsg, // menus/shortcuts -> Msg arms, plain Zig fn
});
```
- `update`/`update_fx`/`init_fx` belong to the adapter (it runs your `initialModel`, `update`, and `subscriptions` through the effect bridge), and the adapter wires `on_command`/`on_key`/`on_appearance`/`on_chrome`/`on_frame` from the core's channel exports automatically (a wiring that also sets one of those seams is a teaching panic). Everything else is ordinary wiring: `tokens_fn`/`windows_fn` derive from `*const core.Model`, and `CoreOptions` carries the adapter-owned knobs (`audio_cache_dir`, `boot_images`, `env_values`).
- Markup binds your model's field names EXACTLY as you wrote them: `nextId` binds as `{nextId}` (the emitted Zig keeps the TS spellings). Record arrays iterate with `<for each="tasks" as="t" key="id">` and items bind their fields (`{t.title}`); optional scalars gate with `<if test="{selected}">` (null is falsy); string-literal unions bind as their member name (`{filter}` renders `all` — compare against a quoted literal, `selected="{sortKey == 'cpu'}"`); exported single-model helpers bind as derived values (`{doneCount}`) and slice-returning ones drive `for each`. Markup `<chart>` series bind number arrays too — `<series kind="bar" values="{cpuSpark}" />` over a field or helper returning `readonly number[]` (emitted f64, narrowed per sample into the chart pipeline); pad a filling window's leading gap with `NaN` samples, which draw nothing.
- `Options.sync` does not exist for TS apps (a committed model cannot be mutated in place): keep continuous controls model-driven — bind the widget's value and echo `on-change`/`on-scroll` Msgs back into the model.
- One live app per core module per process: two apps over the SAME emitted core would share one committed root. Different cores coexist (each emitted core stages its own `rt.zig`).
- Record/replay, the automation verbs, and screenshot fingerprints work unchanged — the adapter rides the standard UiApp dispatch path.
## Checking your work
Scaffolded apps carry an editor surface (`package.json`, `tsconfig.json`, and a CLI-managed `node_modules/@native-sdk/core` copy, npm-managed after the package is published): it exists so stock tsc/editors resolve `@native-sdk/core`, it is NEVER build truth (every `native` verb works with node_modules deleted; check/dev/build re-materialize it), and it is not a language marker — tree detection still keys on `src/core.ts` alone. Do not hand-edit or vendor files under `node_modules/`.
**Refresh the model contract after shape changes.** `native check`'s typed markup pass reads `zig-out/model-contract.zon`, an artifact of the LAST build/test — after changing Model fields or Msg kinds, run `native test` (or `zig build model-contract` in an app that owns its build.zig) before trusting `native check`. A stale contract reports phantom hard errors (`unknown message tag`, `binding does not name a model field`) that name your NEW state — the state is fine; the artifact is old. And never delete `src/app.native` in response to a note about nothing embedding it: on the TypeScript track the generated wiring embeds the view from outside the app tree, so that file is always wired.
The transpiler is the checker: run it after every meaningful edit and read the diagnostics — they always name the rule, the idiomatic rewrite, and the reason. Inside an app, `native check` runs it for you (plus markup and app.zon validation); in a core-only workspace, invoke it directly:
```sh
native check # inside an app
node <sdk-repo>/packages/core/src/cli.ts src/core.ts -o /tmp/core.zig
```
Exit 0 means the module typechecked (real tsc semantics), passed every subset rule, and emitted Zig. Your workspace README shows the exact command paths for your project, plus how to build the emitted core against the runtime kernel. Because the subset is erasable TypeScript, `node` can import your core directly for quick behavioral checks (`node --input-type=module -e "..."` or a small `node --test` file) — the native build has the same semantics. If the core imports `@native-sdk/core` (for `Cmd` or `asciiBytes`), map that one specifier for node first: copy the SDK module file next to the core and rewrite the import, or run through a loader that resolves it.
+208
View File
@@ -0,0 +1,208 @@
---
name: zig
description: Zig 0.16 idioms for Native SDK code, indexed by compile error. Load when `zig build` fails on std APIs with errors like "struct 'fs' has no member named 'cwd'", "struct 'array_list.Aligned(u8,null)' has no member named 'init'", "struct 'std' has no member named 'io'", "no member named 'GeneralPurposeAllocator'", "no member named 'getEnvMap'", or "invalid format string" - the signature of code written for Zig 0.15 or earlier. Covers main(std.process.Init), std.Io file IO and writers, ArrayList, process spawning, environment, clocks and sleep, sockets, custom formatting, and build.zig module shapes, each as this SDK writes them.
---
# Zig 0.16 for Native SDK code
The Native SDK requires Zig 0.16.0 (`minimum_zig_version` in `build.zig.zon`; the CLI pins the same version and offers a checksum-verified download into `~/.native/toolchains/` when the `zig` on PATH does not match). Training data and older guides teach Zig 0.15 idioms, and 0.16 moved everything that touches the outside world — files, stdout, clocks, sleeping, process spawning, sockets — behind an explicit `std.Io` value, while containers became allocator-per-call. Each section below is headed by the exact compile error the old idiom produces, so search this file by error text.
Two rules resolve most failures:
- Operations on the outside world take a `std.Io` first (or right after the receiver). Get one from `init.io` in `main(init: std.process.Init)`, from `std.testing.io` in tests, or from `std.Io.Threaded` in code with no `Init` to thread through.
- Containers are unmanaged: initialize with `.empty` and pass the allocator to every mutating call.
In a UiApp, `update` never sees an `Io` — persistence, subprocesses, HTTP, clocks, and timers go through the typed effects channel (`fx.readFile`, `fx.spawn`, `fx.fetch`, `fx.wallMs`, `fx.startTimer`; see `native skills get native-ui`). Raw `std.Io` belongs in `main`, tests, and standalone tools. And because Zig analyzes lazily, a 0.15-ism can hide in code only one build path references: run BOTH `zig build` and `zig build test` before calling a change done.
## error: struct 'heap' has no member named 'GeneralPurposeAllocator' — allocators come from `main(init: std.process.Init)`
Zig 0.15 and earlier:
```zig
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
}
```
Zig 0.16: `main` takes `std.process.Init`, which carries the process-wide allocators, the `Io`, the environment, and the args — nothing to construct or deinit:
```zig
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa; // general purpose, leak-checked in Debug
const arena = init.arena.allocator(); // process-lifetime arena, freed on exit
const io = init.io; // the Io every std call below wants
const args = try init.minimal.args.toSlice(arena);
if (init.environ_map.get("HOME")) |home| { ... }
}
```
The CLI's own entry point is the live reference: `tools/native-sdk/main.zig`. Generated apps already have this shape — `main(init: std.process.Init)` passes `init` through to `runner.runWithOptions(app, options, init)` and `init.io` to the markup hot-reload watcher (see any `examples/*/src/main.zig`). `std.heap.page_allocator` still exists for allocations that live for the whole process. Library code that cannot take an `Init` builds its own `Io`: `var threaded = std.Io.Threaded.init(allocator, .{});` then `threaded.io()` (`src/platform/macos/root.zig`).
## error: struct 'fs' has no member named 'cwd' — file IO moved to `std.Io.Dir`
`std.fs.cwd()`, `std.fs.File`, and `std.fs.selfExePath` are gone (`std.fs` retains only `path` helpers and deprecated aliases). The directory handle is `std.Io.Dir`, and every operation takes `io` right after the receiver:
```zig
const cwd = std.Io.Dir.cwd();
const content = try cwd.readFileAlloc(io, "app.zon", allocator, .limited(1024 * 1024));
defer allocator.free(content);
try cwd.writeFile(io, .{ .sub_path = "out.txt", .data = bytes });
var file = try cwd.openFile(io, path, .{});
defer file.close(io);
try cwd.deleteTree(io, "zig-out/tmp");
```
Note the `readFileAlloc` argument order (`io`, path, allocator, limit) and the size limit as `std.Io.Limit``.limited(n)` or `.unlimited`. Live references: `tools/native-sdk/skills.zig` (readFileAlloc), `src/tooling/manifest.zig` (openFile), `src/tooling/cef.zig` tests (writeFile with `std.testing.io`). Path buffers size with `std.Io.Dir.max_path_bytes`. For the executable's own path, `std.fs.selfExePath(&buf)` became `std.process.executablePath(io, &buf)`, returning the length (`tools/native-sdk/skills.zig`).
## error: struct 'std' has no member named 'io' — writers are `std.Io.Writer`, stdout is `std.Io.File.stdout()`
`std.io.getStdOut().writer()` and `std.io.fixedBufferStream` are gone. Stdout takes `io` plus a caller-owned buffer, and the printable interface lives one field deep:
```zig
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_writer.interface;
defer stdout.flush() catch {};
try stdout.print("{s}\n", .{message});
```
Forgetting `flush()` means no output — the buffer is yours. `std.debug.print` is unchanged and needs no `io`; prefer it for diagnostics. The `fixedBufferStream` replacement is a fixed writer over a stack buffer, and the written bytes come back from `buffered()`:
```zig
var buf: [256]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
try writer.print("n={d}", .{42});
const written = writer.buffered();
```
For building a string of unknown size, use an allocating writer instead of an ArrayList writer: `var w = std.Io.Writer.Allocating.init(allocator); defer w.deinit();` then print through `w.writer` (`src/automation/server.zig`). One-shot formatting still has `std.fmt.bufPrint` and `std.fmt.allocPrint`, unchanged. Functions that accept a writer take `*std.Io.Writer`, not `anytype` (`tools/native-sdk/skills.zig`; fixed-writer reference: `src/tooling/doctor.zig`).
## error: struct 'array_list.Aligned(u8,null)' has no member named 'init' — ArrayList is unmanaged
`std.ArrayList(T).init(allocator)` is gone; the list no longer stores its allocator. Initialize with `.empty` and pass the allocator to every call that can allocate or free:
```zig
var list: std.ArrayList(u8) = .empty;
defer list.deinit(allocator);
try list.append(allocator, 'a');
try list.appendSlice(allocator, "bc");
const owned = try list.toOwnedSlice(allocator);
```
This is everywhere in the SDK — `src/tooling/templates.zig` builds every generated file this way, `tools/native-sdk/skills.zig` collects skills this way. Passing a managed-style call (`list.append('a')`) fails with "member function expected 2 argument(s), found 1". `std.StringHashMap`/`std.AutoHashMap` still have managed `.init(allocator)` forms; only explicitly `Unmanaged` maps take `.empty` (`tools/native-sdk/markup_lsp.zig`).
## error: invalid format string 's' for type — enums print with `{t}`, format methods with `{f}`
`{s}` is for strings only. Enums and tagged unions print their tag name with `{t}` (equivalent to `@tagName`, which also still works):
```zig
std.debug.print("native {t}: `zig build` step failed (exit code {d})\n", .{ verb, code });
```
(`src/tooling/verbs.zig`.) Custom format methods changed shape twice over: the old four-parameter signature no longer compiles (`error: struct 'fmt' has no member named 'FormatOptions'`), and `{}` no longer calls a `format` method at all — it prints the default field dump silently. Declare the 0.16 signature and print with `{f}`:
```zig
pub fn format(self: Point, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try writer.print("({d},{d})", .{ self.x, self.y });
}
// call site
try writer.print("{f}\n", .{point});
```
## error: struct 'time' has no member named 'sleep' / 'milliTimestamp' — clocks live on Io
`std.time` keeps only the unit constants (`ns_per_ms`, `ms_per_s`, ...) and epoch helpers. Sleeping and reading clocks take `io` and typed durations:
```zig
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(50), .awake);
const now_ns: i128 = std.Io.Timestamp.now(io, .real).nanoseconds;
```
(`src/runtime/automation_liveness_tests.zig`, `src/automation/server.zig`.) In app code, do not reach for either: `native_sdk.nowMs()` / `native_sdk.monotonicMs()` are the facade, `fx.wallMs()` is the journaled read inside `update`, and `model.clock` is the testable seam — the native-ui skill's Time section owns that pattern.
## error: struct 'process.Child' has no member named 'init' — spawning is `std.process.spawn(io, ...)`
The two-step `Child.init` + `child.spawn()` collapsed into one call, and lifecycle methods take `io`:
```zig
var child = try std.process.spawn(io, .{
.argv = &.{ "npm", "run", "build" },
.stdin = .ignore,
.stdout = .inherit,
.stderr = .inherit,
});
const term = try child.wait(io); // child.kill(io) to stop it
```
(`src/tooling/verbs.zig`, `src/tooling/dev.zig` — the latter also shows passing a custom environment via `.environ_map`.) Inside a UiApp, spawn through the effects channel (`fx.spawn`) instead — it is bounded, journaled, and delivers exit/output as Msgs.
## error: struct 'process' has no member named 'getEnvMap' / 'argsAlloc' — environment and args come from Init
`std.process.getEnvMap`, `argsAlloc`, and `argsWithAllocator` are gone. `main` already has both: `init.environ_map` (a `*std.process.Environ.Map`) and `init.minimal.args.toSlice(allocator)` (`tools/native-sdk/main.zig`). Building an environment from scratch — for a child process, or in tests — is `var env = std.process.Environ.Map.init(allocator); defer env.deinit(); try env.put("KEY", "value");` (`src/tooling/dev.zig`).
## error: struct 'mem' has no member named 'trimRight' / 'trimLeft' — renamed `trimEnd` / `trimStart`
The directional trims renamed to match the JS/string convention; arguments are unchanged:
```zig
const line = std.mem.trimEnd(u8, raw, "\r\n"); // was trimRight
const body = std.mem.trimStart(u8, line, " \t"); // was trimLeft
const both = std.mem.trim(u8, text, " "); // unchanged
```
## error: struct 'std' has no member named 'net' — sockets live on `std.Io.net`
`std.net.Address` became `std.Io.net.IpAddress`, and connect/listen take `io`: `std.Io.net.IpAddress.resolve(io, host, port)`, then `IpAddress.connect(&address, io, .{ .mode = .stream, .protocol = .tcp })`; stream readers/writers follow the buffered-writer shape (`std.Io.net.Stream.writer(stream, io, &buffer)` then `.interface`). Live reference: `src/tooling/dev.zig` (`waitUntilReady`/`httpReady`). App-level HTTP belongs in `fx.fetch`, not hand-rolled sockets.
## error: no field named 'root_source_file' in struct 'Build.ExecutableOptions' — build.zig artifacts take modules
Zero-config apps have no `build.zig` — the CLI owns the build graph, so this only appears in apps that ejected or scaffolded `--full`. Artifacts take a module, and the module owns root source, target, and optimize:
```zig
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
const tests = b.addTest(.{ .root_module = app_mod });
```
The generated ejected `build.zig` is the reference shape (`native eject`, emitted by `src/tooling/templates.zig`); the SDK's own `build.zig` uses the same pattern throughout.
## Reading files and streams incrementally
`file.reader()` with no arguments is gone. A reader takes `io` and a buffer, and the stream interface lives on `.interface`:
```zig
var read_buffer: [4096]u8 = undefined;
var reader = file.reader(io, &read_buffer);
const bytes = try reader.interface.allocRemaining(allocator, .limited(1024 * 1024));
const line = try reader.interface.takeDelimiterExclusive('\n');
```
(`src/tooling/manifest.zig` for allocRemaining, `src/tooling/toolchain.zig` for line reading from stdin.) For whole-file reads, prefer `readFileAlloc` above.
## Tests: the Io is `std.testing.io`
Tests never construct an `Io``std.testing.io` is the canonical one, next to `std.testing.allocator`:
```zig
test "reads the manifest" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "app.zon", .data = source });
const content = try tmp.dir.readFileAlloc(std.testing.io, "app.zon", std.testing.allocator, .limited(4096));
defer std.testing.allocator.free(content);
}
```
(`src/tooling/cef.zig` and `src/tooling/manifest.zig` tests.) Model/Msg/update tests need no `Io` at all — the markup-view testing pattern in the native-ui skill is pure.
## Unchanged — do not "migrate" these
`std.fmt.bufPrint` / `allocPrint` / `parseInt` / `parseFloat`, `std.mem.*`, `std.debug.print`, `std.heap.page_allocator`, `std.time.ns_per_*` and `ms_per_*` constants, `@embedFile`, `std.testing.expect*`, and managed `std.StringHashMap` / `std.AutoHashMap` all work as before. If code using only these fails, the problem is elsewhere.