695 lines
31 KiB
TypeScript
695 lines
31 KiB
TypeScript
// @native-sdk/core — the SDK module an app core imports. The subset program
|
|
// maps the "@native-sdk/core" specifier onto this file, so stock tsc types
|
|
// it; the transpiler lowers references to it onto the rt kernel and never
|
|
// emits this module's own code.
|
|
//
|
|
// `Cmd` is the typed-effects surface (spec section 2): `update` (and, since
|
|
// v2, `initialModel`) may return `[model, cmd]`, where the cmd is INERT DATA
|
|
// describing effects for the runtime to perform after the returned model
|
|
// commits. Commands are built only from these factories, and only in that
|
|
// return path (NS1017) — they never live in the Model, in a Msg, in a local,
|
|
// or in a helper.
|
|
//
|
|
// The v2 command set:
|
|
//
|
|
// Cmd.none no effects (what a bare `return model` means)
|
|
// 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
|
|
// single number payload field
|
|
// Cmd.host(name, ...args) a host command by name with scalar args —
|
|
// the host interprets the name
|
|
// Cmd.host(name, payload) the same, carrying one bytes payload — a
|
|
// Uint8Array, or a flat record of number /
|
|
// boolean / Uint8Array fields that lowers to
|
|
// bytes (hostRecordBytes below)
|
|
// Cmd.request(name, payload, a routed host command: the host performs it
|
|
// { key?, ok, err }) and dispatches the `ok` Msg arm with the
|
|
// result bytes, or the `err` arm with the error
|
|
// bytes — each arm carries exactly one
|
|
// Uint8Array payload field, checked by tsc. The
|
|
// optional `key` names the in-flight effect:
|
|
// re-issuing a live key replaces it, and
|
|
// Cmd.cancel(key) drops it.
|
|
// Cmd.cancel(key) drop the in-flight keyed effect — request,
|
|
// readFile/writeFile/fetch/clipboardRead, or
|
|
// delay — SILENTLY (no terminal arm dispatch).
|
|
// Aimed at a live spawn it stays LOUD: the
|
|
// child dies and the err arm runs with
|
|
// "cancelled" — killing a process is an
|
|
// observable event
|
|
// Cmd.batch([a, b, ...]) several commands from one dispatch
|
|
//
|
|
// The named engine ops (each maps onto the host's effect engine directly;
|
|
// routing follows the request rules — string-literal arm names, tsc-checked
|
|
// arm shapes):
|
|
//
|
|
// Cmd.readFile(path, { key?, ok, err })
|
|
// whole-file read; ok arm carries the bytes
|
|
// (one Uint8Array field), err arm the reason
|
|
// bytes ("not_found", "io_failed", "truncated",
|
|
// "rejected")
|
|
// Cmd.writeFile(path, bytes, { key?, ok, err })
|
|
// whole-file write (parents created, replaced
|
|
// whole); ok arm carries NOTHING (an arm with
|
|
// no payload fields), err arm the reason bytes
|
|
// Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })
|
|
// buffered HTTP(S) exchange; ok arm carries a
|
|
// two-field record — one number field (the real
|
|
// HTTP status, non-2xx included) and one
|
|
// Uint8Array field (the whole body) — err arm
|
|
// the reason bytes ("connect_failed",
|
|
// "tls_failed", "protocol_failed", "timed_out",
|
|
// "rejected", "truncated")
|
|
// Cmd.clipboardWrite(bytes) fire-and-forget clipboard write
|
|
// Cmd.clipboardRead({ key?, ok, err })
|
|
// clipboard read; ok arm carries the text bytes,
|
|
// err arm the reason bytes ("failed",
|
|
// "rejected")
|
|
// Cmd.delay(key, ms, "fired") a keyed ONE-SHOT timer: dispatches the named
|
|
// arm once after `ms`, with the fire time (ms)
|
|
// as its single number payload; re-issuing a
|
|
// live key re-arms from now (debounce), and
|
|
// Cmd.cancel(key) drops it silently
|
|
//
|
|
// The streaming ops (one issue, MANY result Msgs across dispatches, a keyed
|
|
// lifecycle the app drives):
|
|
//
|
|
// Cmd.spawn(argv, { key?, stdin?, line?, exit, err })
|
|
// Cmd.spawn(argv, { key?, stdin?, collect: true, exit, err })
|
|
// run a subprocess. Line mode streams each
|
|
// stdout line to the `line` arm (one Uint8Array
|
|
// field; omit `line` to drop lines); collect
|
|
// mode buffers whole stdout instead. Exactly one
|
|
// terminal follows: a clean exit dispatches
|
|
// `exit` — line mode: one number field (the exit
|
|
// code); collect mode: a two-field record, one
|
|
// number (the code) and one Uint8Array (the
|
|
// collected stdout), matched by type — and every
|
|
// other end dispatches `err` with the reason
|
|
// bytes ("signaled", "cancelled", "rejected",
|
|
// "spawn_failed", "truncated"). Cmd.cancel(key)
|
|
// ends the child mid-stream (err arm
|
|
// "cancelled" — never silent).
|
|
// Cmd.audioPlay(key, { path?, url?, cachePath?, expectedBytes? }, { event })
|
|
// open (or replace — one player is the whole
|
|
// surface) the audio event stream: every
|
|
// playback event dispatches the `event` arm (the
|
|
// six-field record below) until Cmd.audioStop
|
|
// closes the stream.
|
|
// Cmd.audioPause(key) / audioResume(key) / audioStop(key)
|
|
// Cmd.audioSeek(key, ms) / Cmd.audioSetVolume(key, volume)
|
|
// drive the open stream in place — fire-and-
|
|
// forget control verbs whose consequences arrive
|
|
// on the event stream; aimed at a key with no
|
|
// open stream they no-op.
|
|
//
|
|
// The keyed-effect discipline is ONE rule: a keyed effect REPLACES its live
|
|
// predecessor (the superseded effect's result is dropped — no message), and
|
|
// Cmd.cancel drops it silently. That holds for request, readFile, writeFile,
|
|
// fetch, clipboardRead, and delay alike. The ONE exception is a live spawn
|
|
// key: a duplicate REJECTS the new spawn (err arm "rejected") — a running
|
|
// subprocess is never killed implicitly; cancel it first. And spawn's cancel
|
|
// stays loud (err arm "cancelled"): killing a process is an observable event.
|
|
//
|
|
// `Sub` is the recurring-effects surface: an app may export
|
|
// `subscriptions(model): Sub<Msg>` returning declarative descriptors the
|
|
// host reconciles after every commit (Sub.timer fires the named arm with the
|
|
// current time on each interval). Like Cmd, Sub values are inert data, legal
|
|
// only in that function's return path (NS1025). Sub and the streaming Cmds
|
|
// are different animals on purpose: a Sub is DECLARED from the model (the
|
|
// host starts/stops it by reconciliation; the app never opens one), while
|
|
// spawn/audioPlay streams are Cmd-INITIATED — imperative opens with a keyed
|
|
// lifecycle the app drives and cancels.
|
|
//
|
|
// The factories return plain frozen-shape objects so the same core runs
|
|
// under node: a dev harness can interpret the `op` tags directly.
|
|
|
|
// The byte-text method surface (s.toUpperCase(), s.split(sep), ... on
|
|
// Uint8Array): the transpiler adds the ambient file to every core's program
|
|
// itself; this reference carries the same surface into EDITORS of apps that
|
|
// import "@native-sdk/core", so tsc-in-the-editor and `native check` agree.
|
|
/// <reference path="./bytes_text_methods.d.ts" />
|
|
|
|
/// The text intrinsic: turn a string literal or template into bytes. The
|
|
/// transpiler recognizes calls BY IDENTITY (this import, renames honored)
|
|
/// and folds them at compile time — a literal argument becomes rodata, a
|
|
/// template becomes frame-arena bytes via bufPrint — so no string ever
|
|
/// exists at native runtime. Under node this body runs as-is, byte for
|
|
/// byte the same result. Arguments must be literals or templates; dynamic
|
|
/// text lives in the model as Uint8Array from the start.
|
|
export function asciiBytes(s: string): Uint8Array {
|
|
const out = new Uint8Array(s.length);
|
|
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
/// Every app Msg is a discriminated union on a string `kind` tag.
|
|
export type Msgish = { readonly kind: string };
|
|
|
|
/// The Msg arms `Cmd.now` may target: arms whose payload is exactly one
|
|
/// number-typed field (the runtime dispatches the arm with the timestamp in
|
|
/// that field). Anything else is unrepresentable — the runtime has only a
|
|
/// number to give back.
|
|
export type TimestampKind<M extends Msgish> = M extends Msgish
|
|
? {
|
|
[K in Exclude<keyof M, "kind">]-?: M[K] extends number
|
|
? [Exclude<keyof M, "kind">] extends [K]
|
|
? M["kind"]
|
|
: never
|
|
: never;
|
|
}[Exclude<keyof M, "kind">]
|
|
: never;
|
|
|
|
/// The Msg arms a routed host result may target: arms whose payload is
|
|
/// exactly one Uint8Array-typed field (the runtime dispatches the arm with
|
|
/// the result/error bytes in that field).
|
|
export type BytesKind<M extends Msgish> = M extends Msgish
|
|
? {
|
|
[K in Exclude<keyof M, "kind">]-?: M[K] extends Uint8Array
|
|
? [Exclude<keyof M, "kind">] extends [K]
|
|
? M["kind"]
|
|
: never
|
|
: never;
|
|
}[Exclude<keyof M, "kind">]
|
|
: never;
|
|
|
|
/// The Msg arms a payload-less routed result may target: arms with no
|
|
/// payload fields at all (`Cmd.writeFile`'s ok route — a successful write
|
|
/// has nothing to report beyond success).
|
|
export type EmptyKind<M extends Msgish> = M extends Msgish
|
|
? [Exclude<keyof M, "kind">] extends [never]
|
|
? M["kind"]
|
|
: never
|
|
: never;
|
|
|
|
/// The Msg arms a buffered fetch response may target: arms whose payload is
|
|
/// exactly two fields — one number (the HTTP status) and one Uint8Array (the
|
|
/// body). The runtime matches the fields by TYPE, so their names are yours.
|
|
export type FetchedKind<M extends Msgish> = M extends Msgish
|
|
? {
|
|
[K in Exclude<keyof M, "kind">]-?: M[K] extends Uint8Array
|
|
? Exclude<keyof M, "kind" | K> extends infer O
|
|
? O extends keyof M
|
|
? M[O] extends number
|
|
? [Exclude<keyof M, "kind" | K | O>] extends [never]
|
|
? M["kind"]
|
|
: never
|
|
: never
|
|
: never
|
|
: never
|
|
: never;
|
|
}[Exclude<keyof M, "kind">]
|
|
: never;
|
|
|
|
// ------------------------------------------------- wiring channel shapes
|
|
// The generated wiring's opt-in host-event channels: export the channel
|
|
// and it is wired (`commandMsg(name: string): Msg | null` is the same
|
|
// family — menus, shortcuts, chrome tabs — and predates these). Event
|
|
// RECORD shapes (`frameMsg`'s FrameEvent, `keyMsg`'s KeyEvent, the
|
|
// appearanceMsg/chromeMsg arm payloads) are DECLARED IN YOUR CORE and
|
|
// matched by field name, the TextInputEvent rule — they must emit as your
|
|
// module's own records, so an SDK interface cannot stand in for them.
|
|
|
|
/// One `envMsgs` entry: `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) as
|
|
/// an ordinary journaled Msg right after the boot command. The core itself
|
|
/// never reads the environment (NS1005); replay carries the recorded values.
|
|
export interface EnvMsg<M extends Msgish> {
|
|
readonly env: string;
|
|
readonly msg: BytesKind<M>;
|
|
}
|
|
|
|
/// The audio event states, mirroring the engine's event vocabulary: `loaded`
|
|
/// acknowledges a successful load with the player's duration estimate;
|
|
/// `position` ticks at the platform's honest cadence (~500ms) while playing;
|
|
/// `completed` fires exactly once at the natural end; `failed` reports a
|
|
/// load/decode/device failure; `rejected` a command the effects layer refused
|
|
/// (an empty or over-long source); `spectrum` carries a band-magnitude
|
|
/// analysis frame from hosts that analyze their playback.
|
|
export type AudioState = "loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum";
|
|
|
|
/// The payload shape of an audio event arm — six fields, matched by NAME (the
|
|
/// one SDK-fixed record shape, so the host can build it from your union).
|
|
/// `state` must be a named string-literal-union alias carrying exactly the
|
|
/// six AudioState members (any declaration order — the host matches members
|
|
/// by name). `positionMs`/`durationMs` are milliseconds; `playing` is the
|
|
/// player's transport state; `buffering` is true while a streamed source is
|
|
/// stalled waiting for network bytes; `bands` is the 32 spectrum band
|
|
/// magnitudes (0..255 each, all zeros outside "spectrum" events).
|
|
export type AudioEventArm = {
|
|
readonly state: AudioState;
|
|
readonly positionMs: number;
|
|
readonly durationMs: number;
|
|
readonly playing: boolean;
|
|
readonly buffering: boolean;
|
|
readonly bands: Uint8Array;
|
|
};
|
|
|
|
/// The Msg arms an audio event stream may target: arms whose payload is
|
|
/// exactly the six AudioEventArm fields.
|
|
export type AudioEventKind<M extends Msgish> = M extends Msgish
|
|
? [Exclude<keyof M, "kind">] extends [keyof AudioEventArm]
|
|
? [keyof AudioEventArm] extends [Exclude<keyof M, "kind">]
|
|
? M extends Msgish & AudioEventArm
|
|
? M["kind"]
|
|
: never
|
|
: never
|
|
: never
|
|
: never;
|
|
|
|
/// One field of a host record payload; see hostRecordBytes for the encoding.
|
|
export type HostScalar = number | boolean | Uint8Array;
|
|
|
|
/// A structured host payload: a flat record of scalar/bytes fields, lowered
|
|
/// to one bytes payload at build time (natively) and by hostRecordBytes
|
|
/// (under node) — the same bytes either way.
|
|
export type HostRecord = { readonly [field: string]: HostScalar };
|
|
|
|
/// How a `Cmd.request` result comes back: the host dispatches the `ok` arm
|
|
/// with the result bytes on success, or the `err` arm with the error bytes.
|
|
/// Arm names are string literals — the routing is data, never a callback.
|
|
/// `key` (optional) names the in-flight effect for replace/cancel semantics.
|
|
export interface RequestRoute<M extends Msgish> {
|
|
readonly key?: string;
|
|
readonly ok: BytesKind<M>;
|
|
readonly err: BytesKind<M>;
|
|
}
|
|
|
|
/// `Cmd.writeFile` routing: the ok arm carries no payload (success has
|
|
/// nothing to report); the err arm carries the reason bytes.
|
|
export interface WriteRoute<M extends Msgish> {
|
|
readonly key?: string;
|
|
readonly ok: EmptyKind<M>;
|
|
readonly err: BytesKind<M>;
|
|
}
|
|
|
|
/// `Cmd.fetch` routing: the ok arm carries `{ status, body }` (one number
|
|
/// field, one bytes field — matched by type); the err arm the reason bytes.
|
|
export interface FetchRoute<M extends Msgish> {
|
|
readonly key?: string;
|
|
readonly ok: FetchedKind<M>;
|
|
readonly err: BytesKind<M>;
|
|
}
|
|
|
|
/// `Cmd.spawn` routing, line mode: each stdout line dispatches the optional
|
|
/// `line` arm (one bytes field; omitted = lines dropped), a clean exit the
|
|
/// `exit` arm (one number field — the exit code), every other end the `err`
|
|
/// arm with the reason bytes.
|
|
export interface SpawnRoute<M extends Msgish> {
|
|
readonly key?: string;
|
|
readonly stdin?: Uint8Array;
|
|
readonly line?: BytesKind<M>;
|
|
readonly exit: TimestampKind<M>;
|
|
readonly err: BytesKind<M>;
|
|
}
|
|
|
|
/// `Cmd.spawn` routing, collect mode: whole stdout buffers until the exit,
|
|
/// which dispatches the `exit` arm as a two-field record — one number field
|
|
/// (the exit code) and one bytes field (the collected stdout), matched by
|
|
/// type so the names are yours. No line arm: there is no line framing.
|
|
export interface SpawnCollectRoute<M extends Msgish> {
|
|
readonly key?: string;
|
|
readonly stdin?: Uint8Array;
|
|
readonly collect: true;
|
|
readonly exit: FetchedKind<M>;
|
|
readonly err: BytesKind<M>;
|
|
}
|
|
|
|
/// A `Cmd.audioPlay` source: the resolution cascade of the engine underneath.
|
|
/// The local `path` is tried first; a missing file falls through to `url`
|
|
/// (streamed progressively, cached at `cachePath` when given and verified
|
|
/// against `expectedBytes` — 0/omitted means unknown size, existence alone
|
|
/// qualifies a cache entry). At least one of path/url must be present.
|
|
export interface AudioSource {
|
|
readonly path?: Uint8Array;
|
|
readonly url?: Uint8Array;
|
|
readonly cachePath?: Uint8Array;
|
|
readonly expectedBytes?: number;
|
|
}
|
|
|
|
/// `Cmd.audioPlay` routing: every playback event dispatches the `event` arm
|
|
/// (the six-field AudioEventArm record, matched by field name).
|
|
export interface AudioRoute<M extends Msgish> {
|
|
readonly event: AudioEventKind<M>;
|
|
}
|
|
|
|
/// The closed HTTP verb set of `Cmd.fetch` (wire value = declaration order).
|
|
export type FetchMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
|
|
|
|
/// A `Cmd.fetch` request. `url` is bytes (asciiBytes for literals); headers
|
|
/// are a flat record whose NAMES are compile-time text and whose VALUES are
|
|
/// string literals or runtime bytes (`Uint8Array` — how a launch-supplied
|
|
/// credential rides an `Authorization` header); `timeoutMs` omitted means
|
|
/// the host engine's default.
|
|
export interface FetchSpec {
|
|
readonly url: Uint8Array;
|
|
readonly method?: FetchMethod;
|
|
readonly headers?: { readonly [name: string]: string | Uint8Array };
|
|
readonly body?: Uint8Array;
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
/// An inert command value, parameterized by the app's Msg union so the
|
|
/// factories can validate message targets. Opaque to app code: build with
|
|
/// the `Cmd.*` factories, return from `update`/`initialModel`, never inspect
|
|
/// or store.
|
|
export type Cmd<M extends Msgish> =
|
|
| { readonly op: "none" }
|
|
| { readonly op: "persist" }
|
|
| { readonly op: "now"; readonly msgKind: string }
|
|
| { readonly op: "host"; readonly name: string; readonly args: readonly number[] }
|
|
| { readonly op: "host_bytes"; readonly name: string; readonly payload: Uint8Array }
|
|
| {
|
|
readonly op: "request";
|
|
readonly name: string;
|
|
readonly key: string;
|
|
readonly okKind: string;
|
|
readonly errKind: string;
|
|
readonly payload: Uint8Array;
|
|
}
|
|
| { readonly op: "cancel"; readonly key: string }
|
|
| {
|
|
readonly op: "read_file";
|
|
readonly key: string;
|
|
readonly okKind: string;
|
|
readonly errKind: string;
|
|
readonly path: Uint8Array;
|
|
}
|
|
| {
|
|
readonly op: "write_file";
|
|
readonly key: string;
|
|
readonly okKind: string;
|
|
readonly errKind: string;
|
|
readonly path: Uint8Array;
|
|
readonly bytes: Uint8Array;
|
|
}
|
|
| {
|
|
readonly op: "fetch";
|
|
readonly key: string;
|
|
readonly okKind: string;
|
|
readonly errKind: string;
|
|
readonly method: FetchMethod;
|
|
readonly timeoutMs: number;
|
|
readonly url: Uint8Array;
|
|
/// Header pairs, already in TS-field-name (code-unit) sort order.
|
|
/// A string value is compile-time text; a Uint8Array value is
|
|
/// runtime bytes (both encode as the record's length-prefixed
|
|
/// value field).
|
|
readonly headers: readonly { readonly name: string; readonly value: string | Uint8Array }[];
|
|
readonly body: Uint8Array;
|
|
}
|
|
| { readonly op: "clip_write"; readonly bytes: Uint8Array }
|
|
| { readonly op: "clip_read"; readonly key: string; readonly okKind: string; readonly errKind: string }
|
|
| { readonly op: "delay"; readonly key: string; readonly afterMs: number; readonly msgKind: string }
|
|
| {
|
|
readonly op: "spawn";
|
|
readonly key: string;
|
|
/// "" = no line routing (collect mode, or a line spawn that only
|
|
/// cares about the exit).
|
|
readonly lineKind: string;
|
|
readonly exitKind: string;
|
|
readonly errKind: string;
|
|
readonly collect: boolean;
|
|
readonly argv: readonly Uint8Array[];
|
|
readonly stdin: Uint8Array;
|
|
}
|
|
| {
|
|
readonly op: "audio_play";
|
|
readonly key: string;
|
|
readonly eventKind: string;
|
|
readonly path: Uint8Array;
|
|
readonly url: Uint8Array;
|
|
readonly cachePath: Uint8Array;
|
|
readonly expectedBytes: number;
|
|
}
|
|
| {
|
|
readonly op: "audio_ctl";
|
|
readonly key: string;
|
|
readonly verb: "pause" | "resume" | "stop" | "seek" | "volume";
|
|
/// Seek position (ms) / volume (0..1); 0 for the value-less verbs.
|
|
readonly value: number;
|
|
}
|
|
| { readonly op: "batch"; readonly cmds: readonly Cmd<M>[] };
|
|
|
|
/// The wire encoding of a host record payload, byte-identical to what the
|
|
/// transpiler derives from the record's TS shape at build time: fields
|
|
/// sorted by name (code-unit order), concatenated with no field headers —
|
|
/// number -> f64 little-endian (8 bytes), boolean -> one 0/1 byte,
|
|
/// Uint8Array -> u32 little-endian length + bytes.
|
|
export function hostRecordBytes(payload: HostRecord): Uint8Array {
|
|
const names = Object.keys(payload).sort();
|
|
let len = 0;
|
|
for (const n of names) {
|
|
const v = payload[n];
|
|
if (typeof v === "number") len += 8;
|
|
else if (typeof v === "boolean") len += 1;
|
|
else len += 4 + v.length;
|
|
}
|
|
const out = new Uint8Array(len);
|
|
const dv = new DataView(out.buffer);
|
|
let off = 0;
|
|
for (const n of names) {
|
|
const v = payload[n];
|
|
if (typeof v === "number") {
|
|
dv.setFloat64(off, v, true);
|
|
off += 8;
|
|
} else if (typeof v === "boolean") {
|
|
out[off] = v ? 1 : 0;
|
|
off += 1;
|
|
} else {
|
|
dv.setUint32(off, v.length, true);
|
|
off += 4;
|
|
out.set(v, off);
|
|
off += v.length;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function lowerHostPayload(payload: Uint8Array | HostRecord): Uint8Array {
|
|
return payload instanceof Uint8Array ? payload : hostRecordBytes(payload);
|
|
}
|
|
|
|
/// A host command by name; the host decides what the name means. The name is
|
|
/// a string literal. Args are scalar numbers, or exactly one bytes payload
|
|
/// (a Uint8Array, or a flat record that lowers to bytes).
|
|
function hostCmd(name: string, payload: Uint8Array | HostRecord): Cmd<never>;
|
|
function hostCmd(name: string, ...args: readonly number[]): Cmd<never>;
|
|
function hostCmd(name: string, ...rest: readonly (number | Uint8Array | HostRecord)[]): Cmd<never> {
|
|
const first = rest[0];
|
|
if (rest.length === 1 && typeof first === "object" && first !== null) {
|
|
return { op: "host_bytes", name, payload: lowerHostPayload(first) };
|
|
}
|
|
return { op: "host", name, args: rest as readonly number[] };
|
|
}
|
|
|
|
export const Cmd = {
|
|
/// No effects. `return model` is sugar for `return [model, Cmd.none]`.
|
|
none: { op: "none" } as Cmd<never>,
|
|
|
|
/// Ask the host to persist the committed model.
|
|
persist(): Cmd<never> {
|
|
return { op: "persist" };
|
|
},
|
|
|
|
/// Request the current time. The runtime dispatches the named Msg arm with
|
|
/// the timestamp (milliseconds, a plain number) as its single payload field.
|
|
now<M extends Msgish>(msgKind: TimestampKind<M>): Cmd<M> {
|
|
return { op: "now", msgKind };
|
|
},
|
|
|
|
host: hostCmd,
|
|
|
|
/// A routed host command: the host performs `name` with the payload and
|
|
/// dispatches exactly one result Msg back — the `ok` arm with the result
|
|
/// bytes, or the `err` arm with the error bytes. Both arms must carry
|
|
/// exactly one Uint8Array payload field (tsc checks that). An optional
|
|
/// `key` names the in-flight effect: re-issuing a live key replaces it,
|
|
/// and Cmd.cancel(key) drops it.
|
|
request<M extends Msgish>(
|
|
name: string,
|
|
payload: Uint8Array | HostRecord,
|
|
route: RequestRoute<M>,
|
|
): Cmd<M> {
|
|
return {
|
|
op: "request",
|
|
name,
|
|
key: route.key ?? "",
|
|
okKind: route.ok,
|
|
errKind: route.err,
|
|
payload: lowerHostPayload(payload),
|
|
};
|
|
},
|
|
|
|
/// Drop the in-flight keyed effect — request, named engine op, or delay —
|
|
/// with this key, if any, SILENTLY (neither routing arm is dispatched for
|
|
/// it). The exception is a live spawn: cancel ends the child and its err
|
|
/// arm runs with "cancelled" — killing a process is an observable event.
|
|
cancel(key: string): Cmd<never> {
|
|
return { op: "cancel", key };
|
|
},
|
|
|
|
/// Read a whole file. Exactly one terminal Msg: the `ok` arm with the
|
|
/// content bytes (one Uint8Array field), or the `err` arm with the reason
|
|
/// bytes ("not_found", "io_failed", "truncated", "rejected").
|
|
readFile<M extends Msgish>(path: Uint8Array, route: RequestRoute<M>): Cmd<M> {
|
|
return { op: "read_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path };
|
|
},
|
|
|
|
/// Write a whole file (parent directories created, an existing file
|
|
/// replaced whole). Exactly one terminal Msg: the `ok` arm — which carries
|
|
/// no payload — or the `err` arm with the reason bytes.
|
|
writeFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M> {
|
|
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
|
|
},
|
|
|
|
/// A buffered HTTP(S) exchange. Exactly one terminal Msg: the `ok` arm
|
|
/// with `{ status, body }` (one number field, one bytes field — a non-2xx
|
|
/// status is still ok: an HTTP-level error is a delivered response), or
|
|
/// the `err` arm with the reason bytes.
|
|
fetch<M extends Msgish>(spec: FetchSpec, route: FetchRoute<M>): Cmd<M> {
|
|
const names = Object.keys(spec.headers ?? {}).sort();
|
|
return {
|
|
op: "fetch",
|
|
key: route.key ?? "",
|
|
okKind: route.ok,
|
|
errKind: route.err,
|
|
method: spec.method ?? "GET",
|
|
timeoutMs: spec.timeoutMs ?? 0,
|
|
url: spec.url,
|
|
headers: names.map((n) => ({ name: n, value: spec.headers![n] })),
|
|
body: spec.body ?? new Uint8Array(0),
|
|
};
|
|
},
|
|
|
|
/// Put bytes on the system clipboard, fire-and-forget (an over-bound or
|
|
/// refused write is dropped — there is no route to report on).
|
|
clipboardWrite(bytes: Uint8Array): Cmd<never> {
|
|
return { op: "clip_write", bytes };
|
|
},
|
|
|
|
/// Read the system clipboard. Exactly one terminal Msg: the `ok` arm with
|
|
/// the text bytes, or the `err` arm with the reason bytes ("failed",
|
|
/// "rejected").
|
|
clipboardRead<M extends Msgish>(route: RequestRoute<M>): Cmd<M> {
|
|
return { op: "clip_read", key: route.key ?? "", okKind: route.ok, errKind: route.err };
|
|
},
|
|
|
|
/// A keyed one-shot delay: dispatch the named Msg arm once, `ms` from now,
|
|
/// with the fire time (milliseconds) as its single number payload field.
|
|
/// Re-issuing a live key re-arms it from now (the debounce discipline);
|
|
/// `Cmd.cancel(key)` drops it silently.
|
|
delay<M extends Msgish>(key: string, ms: number, msgKind: TimestampKind<M>): Cmd<M> {
|
|
return { op: "delay", key, afterMs: ms, msgKind };
|
|
},
|
|
|
|
/// Run a subprocess as a STREAM: each stdout line dispatches the `line`
|
|
/// arm as it arrives (line mode), or whole stdout buffers to the exit
|
|
/// (`collect: true`); exactly one terminal follows — the `exit` arm on a
|
|
/// clean exit, the `err` arm with the reason bytes on every other end.
|
|
/// The key stays live for the whole stream: `Cmd.cancel(key)` ends the
|
|
/// child mid-stream (err arm "cancelled" — loud on purpose: killing a
|
|
/// process is an observable event), and a spawn whose key is already
|
|
/// streaming is rejected, never replaced — a running subprocess is never
|
|
/// killed implicitly; cancel it first.
|
|
spawn<M extends Msgish>(
|
|
argv: readonly Uint8Array[],
|
|
route: SpawnRoute<M> | SpawnCollectRoute<M>,
|
|
): Cmd<M> {
|
|
const collect = "collect" in route && route.collect === true;
|
|
return {
|
|
op: "spawn",
|
|
key: route.key ?? "",
|
|
lineKind: collect ? "" : ((route as SpawnRoute<M>).line ?? ""),
|
|
exitKind: route.exit,
|
|
errKind: route.err,
|
|
collect,
|
|
argv,
|
|
stdin: route.stdin ?? new Uint8Array(0),
|
|
};
|
|
},
|
|
|
|
/// Open (or replace — one player is the whole surface) the keyed audio
|
|
/// event stream: resolve the source cascade (local path, then url, cached
|
|
/// and integrity-gated) and start playback. Every playback event
|
|
/// dispatches the `event` arm until `Cmd.audioStop(key)` closes the
|
|
/// stream. Failure is never silent: an unplayable source arrives as a
|
|
/// "failed" event, a refused command as "rejected".
|
|
audioPlay<M extends Msgish>(key: string, source: AudioSource, route: AudioRoute<M>): Cmd<M> {
|
|
return {
|
|
op: "audio_play",
|
|
key,
|
|
eventKind: route.event,
|
|
path: source.path ?? new Uint8Array(0),
|
|
url: source.url ?? new Uint8Array(0),
|
|
cachePath: source.cachePath ?? new Uint8Array(0),
|
|
expectedBytes: source.expectedBytes ?? 0,
|
|
};
|
|
},
|
|
|
|
/// Pause the keyed playback in place (no event echo — the caller
|
|
/// commanded it). A key with no open stream no-ops.
|
|
audioPause(key: string): Cmd<never> {
|
|
return { op: "audio_ctl", key, verb: "pause", value: 0 };
|
|
},
|
|
|
|
/// Resume the keyed playback. A player that can no longer resume reports
|
|
/// one "failed" event on the stream instead of silence.
|
|
audioResume(key: string): Cmd<never> {
|
|
return { op: "audio_ctl", key, verb: "resume", value: 0 };
|
|
},
|
|
|
|
/// Stop the keyed playback and CLOSE its event stream: no events for the
|
|
/// key after this. Stop is the audio stream's cancel.
|
|
audioStop(key: string): Cmd<never> {
|
|
return { op: "audio_ctl", key, verb: "stop", value: 0 };
|
|
},
|
|
|
|
/// Jump the keyed playback to `ms` (the platform clamps to the duration).
|
|
/// No event echo — the next position tick reports from there.
|
|
audioSeek(key: string, ms: number): Cmd<never> {
|
|
return { op: "audio_ctl", key, verb: "seek", value: ms };
|
|
},
|
|
|
|
/// Set playback volume, clamped to 0..1 and remembered across tracks: the
|
|
/// next audioPlay re-applies it.
|
|
audioSetVolume(key: string, volume: number): Cmd<never> {
|
|
return { op: "audio_ctl", key, verb: "volume", value: volume };
|
|
},
|
|
|
|
/// Several commands from one dispatch, performed in order.
|
|
batch<M extends Msgish>(cmds: readonly Cmd<M>[]): Cmd<M> {
|
|
return { op: "batch", cmds };
|
|
},
|
|
};
|
|
|
|
/// An inert subscription descriptor: recurring effects declared FROM the
|
|
/// model. An app that needs them exports `subscriptions(model): Sub<Msg>`;
|
|
/// after every commit the host reconciles the returned set against its
|
|
/// active timers by key (new key or changed interval re-arms; a missing key
|
|
/// cancels). Like Cmd, Sub values are data, legal only in that function's
|
|
/// return path (NS1025).
|
|
export type Sub<M extends Msgish> =
|
|
| { readonly op: "none" }
|
|
| { readonly op: "timer"; readonly key: string; readonly everyMs: number; readonly msgKind: string }
|
|
| { readonly op: "batch"; readonly subs: readonly Sub<M>[] };
|
|
|
|
export const Sub = {
|
|
/// No subscriptions (e.g. everything paused).
|
|
none: { op: "none" } as Sub<never>,
|
|
|
|
/// A repeating timer named by `key`, firing every `everyMs` milliseconds.
|
|
/// Each fire dispatches the named Msg arm with the current time (ms) as
|
|
/// its single number payload field.
|
|
timer<M extends Msgish>(key: string, everyMs: number, msgKind: TimestampKind<M>): Sub<M> {
|
|
return { op: "timer", key, everyMs, msgKind };
|
|
},
|
|
|
|
/// Several subscriptions at once.
|
|
batch<M extends Msgish>(subs: readonly Sub<M>[]): Sub<M> {
|
|
return { op: "batch", subs };
|
|
},
|
|
};
|