Skip to main content

ts-core

Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1069), 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, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app manifest 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). Use ts-services alongside this guide for src/services work.

ソース情報

リポジトリ
vercel-labs/native
ソースの最終更新活動
2026年10月14日 02:13
検出された SKILL.md の言語
英語
スター
7,779
フォーク
326

インストール方法

デフォルトでは、最初にソースを確認する Prompt が選択されています。直接コマンドに切り替えるか、ローカルコピーをダウンロードすることもできます。

ソースファイルを確認

インストールを決める前に、SKILL.md と SkillsMP に表示されている付属ファイルをお読みください。

SKILL.md を表示中

SKILL.md
ソースの指示 · 読み取り専用プレビュー
name
ts-core
description
Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1069), 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, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app manifest 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). Use ts-services alongside this guide for src/services work.
# Author app cores in the TypeScript subset TypeScript is the primary app-authoring language. An app core is a Native SDK app's deterministic logic: `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 build checks the whole import graph with the `@native-sdk/core` frontend and compiles it to native code with the external core compiler. 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 starts as three files of truth and zero Zig: `src/core.ts` (this guide; plus core-class modules it imports under `src/`), `src/app.native` (the markup view over the core's model), and `app.json` (windows, identity, permissions). Existing `app.zon` manifests remain supported. Optional ordinary-TypeScript service modules live under `src/services/` and are never imported by the core; load `native skills get ts-services` for that boundary. `native init` scaffolds the three-file base; 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.json/app.zon native build # ReleaseFast binary; native test runs the app's tests ``` The complete reference app in this idiom is `examples/soundboard` 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 native 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 binding 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. ## Tests against the compiled app Put Node test files directly in `tests/*.test.ts` and run `native test`. Import `NativeApp` and `findWidget` from `@native-sdk/core/testing` **only in tests**, never in `src/core.ts`. Each `NativeApp.start()` launches a separate headless native process with the real scriptc-compiled core and markup. Drive `click`/`action`/`menu`, assert returned model and accessibility snapshots, inspect and answer fake requests with `respond`, and fire declared timers with `fireTimer`. Supply explicit declared launch values with `NativeApp.start({ environment: [{ name: "APP_MODE", value: "manual" }] })`; they are journaled and replay does not read ambient environment. Framework app directories still use `appDataDirectory`. Always `close()` in `finally` or use `await using`. `verifyReplay()` verifies checkpoints and the final model in a fresh runtime; afterward only snapshot/close are allowed. Model bytes are JSON number arrays; native identities are decimal strings. The host uses a fixed clock and fake effects, without production persistence/services/app-data startup. See https://native-sdk.dev/docs/testing for the complete API and limits. ## 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 are the compiled core's own: the frame arena bounds one dispatch's transients, the model heap holds the committed model between dispatches, and the compiler's determinism fences keep every dispatch allocation-shaped and replayable. ## 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 checker 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 checker 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 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`/`utf8Bytes` 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.
GitHubで見る
この SKILL.md は非常に大きいため、SkillsMP では最初のセクションだけを表示しています。 GitHubで見る