@native-sdk/core 0.0.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sdk/core.ts ADDED
@@ -0,0 +1,694 @@
1
+ // @native-sdk/core — the SDK module an app core imports. The subset program
2
+ // maps the "@native-sdk/core" specifier onto this file, so stock tsc types
3
+ // it; the transpiler lowers references to it onto the rt kernel and never
4
+ // emits this module's own code.
5
+ //
6
+ // `Cmd` is the typed-effects surface (spec section 2): `update` (and, since
7
+ // v2, `initialModel`) may return `[model, cmd]`, where the cmd is INERT DATA
8
+ // describing effects for the runtime to perform after the returned model
9
+ // commits. Commands are built only from these factories, and only in that
10
+ // return path (NS1017) — they never live in the Model, in a Msg, in a local,
11
+ // or in a helper.
12
+ //
13
+ // The v2 command set:
14
+ //
15
+ // Cmd.none no effects (what a bare `return model` means)
16
+ // Cmd.persist() ask the host to persist the committed model
17
+ // Cmd.now("tick") request a timestamp; the runtime dispatches
18
+ // the named Msg arm with the time (ms) as its
19
+ // single number payload field
20
+ // Cmd.host(name, ...args) a host command by name with scalar args —
21
+ // the host interprets the name
22
+ // Cmd.host(name, payload) the same, carrying one bytes payload — a
23
+ // Uint8Array, or a flat record of number /
24
+ // boolean / Uint8Array fields that lowers to
25
+ // bytes (hostRecordBytes below)
26
+ // Cmd.request(name, payload, a routed host command: the host performs it
27
+ // { key?, ok, err }) and dispatches the `ok` Msg arm with the
28
+ // result bytes, or the `err` arm with the error
29
+ // bytes — each arm carries exactly one
30
+ // Uint8Array payload field, checked by tsc. The
31
+ // optional `key` names the in-flight effect:
32
+ // re-issuing a live key replaces it, and
33
+ // Cmd.cancel(key) drops it.
34
+ // Cmd.cancel(key) drop the in-flight keyed effect — request,
35
+ // readFile/writeFile/fetch/clipboardRead, or
36
+ // delay — SILENTLY (no terminal arm dispatch).
37
+ // Aimed at a live spawn it stays LOUD: the
38
+ // child dies and the err arm runs with
39
+ // "cancelled" — killing a process is an
40
+ // observable event
41
+ // Cmd.batch([a, b, ...]) several commands from one dispatch
42
+ //
43
+ // The named engine ops (each maps onto the host's effect engine directly;
44
+ // routing follows the request rules — string-literal arm names, tsc-checked
45
+ // arm shapes):
46
+ //
47
+ // Cmd.readFile(path, { key?, ok, err })
48
+ // whole-file read; ok arm carries the bytes
49
+ // (one Uint8Array field), err arm the reason
50
+ // bytes ("not_found", "io_failed", "truncated",
51
+ // "rejected")
52
+ // Cmd.writeFile(path, bytes, { key?, ok, err })
53
+ // whole-file write (parents created, replaced
54
+ // whole); ok arm carries NOTHING (an arm with
55
+ // no payload fields), err arm the reason bytes
56
+ // Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })
57
+ // buffered HTTP(S) exchange; ok arm carries a
58
+ // two-field record — one number field (the real
59
+ // HTTP status, non-2xx included) and one
60
+ // Uint8Array field (the whole body) — err arm
61
+ // the reason bytes ("connect_failed",
62
+ // "tls_failed", "protocol_failed", "timed_out",
63
+ // "rejected", "truncated")
64
+ // Cmd.clipboardWrite(bytes) fire-and-forget clipboard write
65
+ // Cmd.clipboardRead({ key?, ok, err })
66
+ // clipboard read; ok arm carries the text bytes,
67
+ // err arm the reason bytes ("failed",
68
+ // "rejected")
69
+ // Cmd.delay(key, ms, "fired") a keyed ONE-SHOT timer: dispatches the named
70
+ // arm once after `ms`, with the fire time (ms)
71
+ // as its single number payload; re-issuing a
72
+ // live key re-arms from now (debounce), and
73
+ // Cmd.cancel(key) drops it silently
74
+ //
75
+ // The streaming ops (one issue, MANY result Msgs across dispatches, a keyed
76
+ // lifecycle the app drives):
77
+ //
78
+ // Cmd.spawn(argv, { key?, stdin?, line?, exit, err })
79
+ // Cmd.spawn(argv, { key?, stdin?, collect: true, exit, err })
80
+ // run a subprocess. Line mode streams each
81
+ // stdout line to the `line` arm (one Uint8Array
82
+ // field; omit `line` to drop lines); collect
83
+ // mode buffers whole stdout instead. Exactly one
84
+ // terminal follows: a clean exit dispatches
85
+ // `exit` — line mode: one number field (the exit
86
+ // code); collect mode: a two-field record, one
87
+ // number (the code) and one Uint8Array (the
88
+ // collected stdout), matched by type — and every
89
+ // other end dispatches `err` with the reason
90
+ // bytes ("signaled", "cancelled", "rejected",
91
+ // "spawn_failed", "truncated"). Cmd.cancel(key)
92
+ // ends the child mid-stream (err arm
93
+ // "cancelled" — never silent).
94
+ // Cmd.audioPlay(key, { path?, url?, cachePath?, expectedBytes? }, { event })
95
+ // open (or replace — one player is the whole
96
+ // surface) the audio event stream: every
97
+ // playback event dispatches the `event` arm (the
98
+ // six-field record below) until Cmd.audioStop
99
+ // closes the stream.
100
+ // Cmd.audioPause(key) / audioResume(key) / audioStop(key)
101
+ // Cmd.audioSeek(key, ms) / Cmd.audioSetVolume(key, volume)
102
+ // drive the open stream in place — fire-and-
103
+ // forget control verbs whose consequences arrive
104
+ // on the event stream; aimed at a key with no
105
+ // open stream they no-op.
106
+ //
107
+ // The keyed-effect discipline is ONE rule: a keyed effect REPLACES its live
108
+ // predecessor (the superseded effect's result is dropped — no message), and
109
+ // Cmd.cancel drops it silently. That holds for request, readFile, writeFile,
110
+ // fetch, clipboardRead, and delay alike. The ONE exception is a live spawn
111
+ // key: a duplicate REJECTS the new spawn (err arm "rejected") — a running
112
+ // subprocess is never killed implicitly; cancel it first. And spawn's cancel
113
+ // stays loud (err arm "cancelled"): killing a process is an observable event.
114
+ //
115
+ // `Sub` is the recurring-effects surface: an app may export
116
+ // `subscriptions(model): Sub<Msg>` returning declarative descriptors the
117
+ // host reconciles after every commit (Sub.timer fires the named arm with the
118
+ // current time on each interval). Like Cmd, Sub values are inert data, legal
119
+ // only in that function's return path (NS1025). Sub and the streaming Cmds
120
+ // are different animals on purpose: a Sub is DECLARED from the model (the
121
+ // host starts/stops it by reconciliation; the app never opens one), while
122
+ // spawn/audioPlay streams are Cmd-INITIATED — imperative opens with a keyed
123
+ // lifecycle the app drives and cancels.
124
+ //
125
+ // The factories return plain frozen-shape objects so the same core runs
126
+ // under node: a dev harness can interpret the `op` tags directly.
127
+
128
+ // The byte-text method surface (s.toUpperCase(), s.split(sep), ... on
129
+ // Uint8Array): the transpiler adds the ambient file to every core's program
130
+ // itself; this reference carries the same surface into EDITORS of apps that
131
+ // import "@native-sdk/core", so tsc-in-the-editor and `native check` agree.
132
+ /// <reference path="./bytes_text_methods.d.ts" />
133
+
134
+ /// The text intrinsic: turn a string literal or template into bytes. The
135
+ /// transpiler recognizes calls BY IDENTITY (this import, renames honored)
136
+ /// and folds them at compile time — a literal argument becomes rodata, a
137
+ /// template becomes frame-arena bytes via bufPrint — so no string ever
138
+ /// exists at native runtime. Under node this body runs as-is, byte for
139
+ /// byte the same result. Arguments must be literals or templates; dynamic
140
+ /// text lives in the model as Uint8Array from the start.
141
+ export function asciiBytes(s: string): Uint8Array {
142
+ const out = new Uint8Array(s.length);
143
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
144
+ return out;
145
+ }
146
+
147
+ /// Every app Msg is a discriminated union on a string `kind` tag.
148
+ export type Msgish = { readonly kind: string };
149
+
150
+ /// The Msg arms `Cmd.now` may target: arms whose payload is exactly one
151
+ /// number-typed field (the runtime dispatches the arm with the timestamp in
152
+ /// that field). Anything else is unrepresentable — the runtime has only a
153
+ /// number to give back.
154
+ export type TimestampKind<M extends Msgish> = M extends Msgish
155
+ ? {
156
+ [K in Exclude<keyof M, "kind">]-?: M[K] extends number
157
+ ? [Exclude<keyof M, "kind">] extends [K]
158
+ ? M["kind"]
159
+ : never
160
+ : never;
161
+ }[Exclude<keyof M, "kind">]
162
+ : never;
163
+
164
+ /// The Msg arms a routed host result may target: arms whose payload is
165
+ /// exactly one Uint8Array-typed field (the runtime dispatches the arm with
166
+ /// the result/error bytes in that field).
167
+ export type BytesKind<M extends Msgish> = M extends Msgish
168
+ ? {
169
+ [K in Exclude<keyof M, "kind">]-?: M[K] extends Uint8Array
170
+ ? [Exclude<keyof M, "kind">] extends [K]
171
+ ? M["kind"]
172
+ : never
173
+ : never;
174
+ }[Exclude<keyof M, "kind">]
175
+ : never;
176
+
177
+ /// The Msg arms a payload-less routed result may target: arms with no
178
+ /// payload fields at all (`Cmd.writeFile`'s ok route — a successful write
179
+ /// has nothing to report beyond success).
180
+ export type EmptyKind<M extends Msgish> = M extends Msgish
181
+ ? [Exclude<keyof M, "kind">] extends [never]
182
+ ? M["kind"]
183
+ : never
184
+ : never;
185
+
186
+ /// The Msg arms a buffered fetch response may target: arms whose payload is
187
+ /// exactly two fields — one number (the HTTP status) and one Uint8Array (the
188
+ /// body). The runtime matches the fields by TYPE, so their names are yours.
189
+ export type FetchedKind<M extends Msgish> = M extends Msgish
190
+ ? {
191
+ [K in Exclude<keyof M, "kind">]-?: M[K] extends Uint8Array
192
+ ? Exclude<keyof M, "kind" | K> extends infer O
193
+ ? O extends keyof M
194
+ ? M[O] extends number
195
+ ? [Exclude<keyof M, "kind" | K | O>] extends [never]
196
+ ? M["kind"]
197
+ : never
198
+ : never
199
+ : never
200
+ : never
201
+ : never;
202
+ }[Exclude<keyof M, "kind">]
203
+ : never;
204
+
205
+ // ------------------------------------------------- wiring channel shapes
206
+ // The generated wiring's opt-in host-event channels: export the channel
207
+ // and it is wired (`commandMsg(name: string): Msg | null` is the same
208
+ // family — menus, shortcuts, chrome tabs — and predates these). Event
209
+ // RECORD shapes (`frameMsg`'s FrameEvent, `keyMsg`'s KeyEvent, the
210
+ // appearanceMsg/chromeMsg arm payloads) are DECLARED IN YOUR CORE and
211
+ // matched by field name, the TextInputEvent rule — they must emit as your
212
+ // module's own records, so an SDK interface cannot stand in for them.
213
+
214
+ /// One `envMsgs` entry: `export const envMsgs = [{ env: "NAME", msg:
215
+ /// "<arm>" }] as const` — each named environment variable present at launch
216
+ /// dispatches its value through the arm (exactly one `Uint8Array` field) as
217
+ /// an ordinary journaled Msg right after the boot command. The core itself
218
+ /// never reads the environment (NS1005); replay carries the recorded values.
219
+ export interface EnvMsg<M extends Msgish> {
220
+ readonly env: string;
221
+ readonly msg: BytesKind<M>;
222
+ }
223
+
224
+ /// The audio event states, mirroring the engine's event vocabulary: `loaded`
225
+ /// acknowledges a successful load with the player's duration estimate;
226
+ /// `position` ticks at the platform's honest cadence (~500ms) while playing;
227
+ /// `completed` fires exactly once at the natural end; `failed` reports a
228
+ /// load/decode/device failure; `rejected` a command the effects layer refused
229
+ /// (an empty or over-long source); `spectrum` carries a band-magnitude
230
+ /// analysis frame from hosts that analyze their playback.
231
+ export type AudioState = "loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum";
232
+
233
+ /// The payload shape of an audio event arm — six fields, matched by NAME (the
234
+ /// one SDK-fixed record shape, so the host can build it from your union).
235
+ /// `state` must be a named string-literal-union alias carrying exactly the
236
+ /// six AudioState members (any declaration order — the host matches members
237
+ /// by name). `positionMs`/`durationMs` are milliseconds; `playing` is the
238
+ /// player's transport state; `buffering` is true while a streamed source is
239
+ /// stalled waiting for network bytes; `bands` is the 32 spectrum band
240
+ /// magnitudes (0..255 each, all zeros outside "spectrum" events).
241
+ export type AudioEventArm = {
242
+ readonly state: AudioState;
243
+ readonly positionMs: number;
244
+ readonly durationMs: number;
245
+ readonly playing: boolean;
246
+ readonly buffering: boolean;
247
+ readonly bands: Uint8Array;
248
+ };
249
+
250
+ /// The Msg arms an audio event stream may target: arms whose payload is
251
+ /// exactly the six AudioEventArm fields.
252
+ export type AudioEventKind<M extends Msgish> = M extends Msgish
253
+ ? [Exclude<keyof M, "kind">] extends [keyof AudioEventArm]
254
+ ? [keyof AudioEventArm] extends [Exclude<keyof M, "kind">]
255
+ ? M extends Msgish & AudioEventArm
256
+ ? M["kind"]
257
+ : never
258
+ : never
259
+ : never
260
+ : never;
261
+
262
+ /// One field of a host record payload; see hostRecordBytes for the encoding.
263
+ export type HostScalar = number | boolean | Uint8Array;
264
+
265
+ /// A structured host payload: a flat record of scalar/bytes fields, lowered
266
+ /// to one bytes payload at build time (natively) and by hostRecordBytes
267
+ /// (under node) — the same bytes either way.
268
+ export type HostRecord = { readonly [field: string]: HostScalar };
269
+
270
+ /// How a `Cmd.request` result comes back: the host dispatches the `ok` arm
271
+ /// with the result bytes on success, or the `err` arm with the error bytes.
272
+ /// Arm names are string literals — the routing is data, never a callback.
273
+ /// `key` (optional) names the in-flight effect for replace/cancel semantics.
274
+ export interface RequestRoute<M extends Msgish> {
275
+ readonly key?: string;
276
+ readonly ok: BytesKind<M>;
277
+ readonly err: BytesKind<M>;
278
+ }
279
+
280
+ /// `Cmd.writeFile` routing: the ok arm carries no payload (success has
281
+ /// nothing to report); the err arm carries the reason bytes.
282
+ export interface WriteRoute<M extends Msgish> {
283
+ readonly key?: string;
284
+ readonly ok: EmptyKind<M>;
285
+ readonly err: BytesKind<M>;
286
+ }
287
+
288
+ /// `Cmd.fetch` routing: the ok arm carries `{ status, body }` (one number
289
+ /// field, one bytes field — matched by type); the err arm the reason bytes.
290
+ export interface FetchRoute<M extends Msgish> {
291
+ readonly key?: string;
292
+ readonly ok: FetchedKind<M>;
293
+ readonly err: BytesKind<M>;
294
+ }
295
+
296
+ /// `Cmd.spawn` routing, line mode: each stdout line dispatches the optional
297
+ /// `line` arm (one bytes field; omitted = lines dropped), a clean exit the
298
+ /// `exit` arm (one number field — the exit code), every other end the `err`
299
+ /// arm with the reason bytes.
300
+ export interface SpawnRoute<M extends Msgish> {
301
+ readonly key?: string;
302
+ readonly stdin?: Uint8Array;
303
+ readonly line?: BytesKind<M>;
304
+ readonly exit: TimestampKind<M>;
305
+ readonly err: BytesKind<M>;
306
+ }
307
+
308
+ /// `Cmd.spawn` routing, collect mode: whole stdout buffers until the exit,
309
+ /// which dispatches the `exit` arm as a two-field record — one number field
310
+ /// (the exit code) and one bytes field (the collected stdout), matched by
311
+ /// type so the names are yours. No line arm: there is no line framing.
312
+ export interface SpawnCollectRoute<M extends Msgish> {
313
+ readonly key?: string;
314
+ readonly stdin?: Uint8Array;
315
+ readonly collect: true;
316
+ readonly exit: FetchedKind<M>;
317
+ readonly err: BytesKind<M>;
318
+ }
319
+
320
+ /// A `Cmd.audioPlay` source: the resolution cascade of the engine underneath.
321
+ /// The local `path` is tried first; a missing file falls through to `url`
322
+ /// (streamed progressively, cached at `cachePath` when given and verified
323
+ /// against `expectedBytes` — 0/omitted means unknown size, existence alone
324
+ /// qualifies a cache entry). At least one of path/url must be present.
325
+ export interface AudioSource {
326
+ readonly path?: Uint8Array;
327
+ readonly url?: Uint8Array;
328
+ readonly cachePath?: Uint8Array;
329
+ readonly expectedBytes?: number;
330
+ }
331
+
332
+ /// `Cmd.audioPlay` routing: every playback event dispatches the `event` arm
333
+ /// (the six-field AudioEventArm record, matched by field name).
334
+ export interface AudioRoute<M extends Msgish> {
335
+ readonly event: AudioEventKind<M>;
336
+ }
337
+
338
+ /// The closed HTTP verb set of `Cmd.fetch` (wire value = declaration order).
339
+ export type FetchMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
340
+
341
+ /// A `Cmd.fetch` request. `url` is bytes (asciiBytes for literals); headers
342
+ /// are a flat record whose NAMES are compile-time text and whose VALUES are
343
+ /// string literals or runtime bytes (`Uint8Array` — how a launch-supplied
344
+ /// credential rides an `Authorization` header); `timeoutMs` omitted means
345
+ /// the host engine's default.
346
+ export interface FetchSpec {
347
+ readonly url: Uint8Array;
348
+ readonly method?: FetchMethod;
349
+ readonly headers?: { readonly [name: string]: string | Uint8Array };
350
+ readonly body?: Uint8Array;
351
+ readonly timeoutMs?: number;
352
+ }
353
+
354
+ /// An inert command value, parameterized by the app's Msg union so the
355
+ /// factories can validate message targets. Opaque to app code: build with
356
+ /// the `Cmd.*` factories, return from `update`/`initialModel`, never inspect
357
+ /// or store.
358
+ export type Cmd<M extends Msgish> =
359
+ | { readonly op: "none" }
360
+ | { readonly op: "persist" }
361
+ | { readonly op: "now"; readonly msgKind: string }
362
+ | { readonly op: "host"; readonly name: string; readonly args: readonly number[] }
363
+ | { readonly op: "host_bytes"; readonly name: string; readonly payload: Uint8Array }
364
+ | {
365
+ readonly op: "request";
366
+ readonly name: string;
367
+ readonly key: string;
368
+ readonly okKind: string;
369
+ readonly errKind: string;
370
+ readonly payload: Uint8Array;
371
+ }
372
+ | { readonly op: "cancel"; readonly key: string }
373
+ | {
374
+ readonly op: "read_file";
375
+ readonly key: string;
376
+ readonly okKind: string;
377
+ readonly errKind: string;
378
+ readonly path: Uint8Array;
379
+ }
380
+ | {
381
+ readonly op: "write_file";
382
+ readonly key: string;
383
+ readonly okKind: string;
384
+ readonly errKind: string;
385
+ readonly path: Uint8Array;
386
+ readonly bytes: Uint8Array;
387
+ }
388
+ | {
389
+ readonly op: "fetch";
390
+ readonly key: string;
391
+ readonly okKind: string;
392
+ readonly errKind: string;
393
+ readonly method: FetchMethod;
394
+ readonly timeoutMs: number;
395
+ readonly url: Uint8Array;
396
+ /// Header pairs, already in TS-field-name (code-unit) sort order.
397
+ /// A string value is compile-time text; a Uint8Array value is
398
+ /// runtime bytes (both encode as the record's length-prefixed
399
+ /// value field).
400
+ readonly headers: readonly { readonly name: string; readonly value: string | Uint8Array }[];
401
+ readonly body: Uint8Array;
402
+ }
403
+ | { readonly op: "clip_write"; readonly bytes: Uint8Array }
404
+ | { readonly op: "clip_read"; readonly key: string; readonly okKind: string; readonly errKind: string }
405
+ | { readonly op: "delay"; readonly key: string; readonly afterMs: number; readonly msgKind: string }
406
+ | {
407
+ readonly op: "spawn";
408
+ readonly key: string;
409
+ /// "" = no line routing (collect mode, or a line spawn that only
410
+ /// cares about the exit).
411
+ readonly lineKind: string;
412
+ readonly exitKind: string;
413
+ readonly errKind: string;
414
+ readonly collect: boolean;
415
+ readonly argv: readonly Uint8Array[];
416
+ readonly stdin: Uint8Array;
417
+ }
418
+ | {
419
+ readonly op: "audio_play";
420
+ readonly key: string;
421
+ readonly eventKind: string;
422
+ readonly path: Uint8Array;
423
+ readonly url: Uint8Array;
424
+ readonly cachePath: Uint8Array;
425
+ readonly expectedBytes: number;
426
+ }
427
+ | {
428
+ readonly op: "audio_ctl";
429
+ readonly key: string;
430
+ readonly verb: "pause" | "resume" | "stop" | "seek" | "volume";
431
+ /// Seek position (ms) / volume (0..1); 0 for the value-less verbs.
432
+ readonly value: number;
433
+ }
434
+ | { readonly op: "batch"; readonly cmds: readonly Cmd<M>[] };
435
+
436
+ /// The wire encoding of a host record payload, byte-identical to what the
437
+ /// transpiler derives from the record's TS shape at build time: fields
438
+ /// sorted by name (code-unit order), concatenated with no field headers —
439
+ /// number -> f64 little-endian (8 bytes), boolean -> one 0/1 byte,
440
+ /// Uint8Array -> u32 little-endian length + bytes.
441
+ export function hostRecordBytes(payload: HostRecord): Uint8Array {
442
+ const names = Object.keys(payload).sort();
443
+ let len = 0;
444
+ for (const n of names) {
445
+ const v = payload[n];
446
+ if (typeof v === "number") len += 8;
447
+ else if (typeof v === "boolean") len += 1;
448
+ else len += 4 + v.length;
449
+ }
450
+ const out = new Uint8Array(len);
451
+ const dv = new DataView(out.buffer);
452
+ let off = 0;
453
+ for (const n of names) {
454
+ const v = payload[n];
455
+ if (typeof v === "number") {
456
+ dv.setFloat64(off, v, true);
457
+ off += 8;
458
+ } else if (typeof v === "boolean") {
459
+ out[off] = v ? 1 : 0;
460
+ off += 1;
461
+ } else {
462
+ dv.setUint32(off, v.length, true);
463
+ off += 4;
464
+ out.set(v, off);
465
+ off += v.length;
466
+ }
467
+ }
468
+ return out;
469
+ }
470
+
471
+ function lowerHostPayload(payload: Uint8Array | HostRecord): Uint8Array {
472
+ return payload instanceof Uint8Array ? payload : hostRecordBytes(payload);
473
+ }
474
+
475
+ /// A host command by name; the host decides what the name means. The name is
476
+ /// a string literal. Args are scalar numbers, or exactly one bytes payload
477
+ /// (a Uint8Array, or a flat record that lowers to bytes).
478
+ function hostCmd(name: string, payload: Uint8Array | HostRecord): Cmd<never>;
479
+ function hostCmd(name: string, ...args: readonly number[]): Cmd<never>;
480
+ function hostCmd(name: string, ...rest: readonly (number | Uint8Array | HostRecord)[]): Cmd<never> {
481
+ const first = rest[0];
482
+ if (rest.length === 1 && typeof first === "object" && first !== null) {
483
+ return { op: "host_bytes", name, payload: lowerHostPayload(first) };
484
+ }
485
+ return { op: "host", name, args: rest as readonly number[] };
486
+ }
487
+
488
+ export const Cmd = {
489
+ /// No effects. `return model` is sugar for `return [model, Cmd.none]`.
490
+ none: { op: "none" } as Cmd<never>,
491
+
492
+ /// Ask the host to persist the committed model.
493
+ persist(): Cmd<never> {
494
+ return { op: "persist" };
495
+ },
496
+
497
+ /// Request the current time. The runtime dispatches the named Msg arm with
498
+ /// the timestamp (milliseconds, a plain number) as its single payload field.
499
+ now<M extends Msgish>(msgKind: TimestampKind<M>): Cmd<M> {
500
+ return { op: "now", msgKind };
501
+ },
502
+
503
+ host: hostCmd,
504
+
505
+ /// A routed host command: the host performs `name` with the payload and
506
+ /// dispatches exactly one result Msg back — the `ok` arm with the result
507
+ /// bytes, or the `err` arm with the error bytes. Both arms must carry
508
+ /// exactly one Uint8Array payload field (tsc checks that). An optional
509
+ /// `key` names the in-flight effect: re-issuing a live key replaces it,
510
+ /// and Cmd.cancel(key) drops it.
511
+ request<M extends Msgish>(
512
+ name: string,
513
+ payload: Uint8Array | HostRecord,
514
+ route: RequestRoute<M>,
515
+ ): Cmd<M> {
516
+ return {
517
+ op: "request",
518
+ name,
519
+ key: route.key ?? "",
520
+ okKind: route.ok,
521
+ errKind: route.err,
522
+ payload: lowerHostPayload(payload),
523
+ };
524
+ },
525
+
526
+ /// Drop the in-flight keyed effect — request, named engine op, or delay —
527
+ /// with this key, if any, SILENTLY (neither routing arm is dispatched for
528
+ /// it). The exception is a live spawn: cancel ends the child and its err
529
+ /// arm runs with "cancelled" — killing a process is an observable event.
530
+ cancel(key: string): Cmd<never> {
531
+ return { op: "cancel", key };
532
+ },
533
+
534
+ /// Read a whole file. Exactly one terminal Msg: the `ok` arm with the
535
+ /// content bytes (one Uint8Array field), or the `err` arm with the reason
536
+ /// bytes ("not_found", "io_failed", "truncated", "rejected").
537
+ readFile<M extends Msgish>(path: Uint8Array, route: RequestRoute<M>): Cmd<M> {
538
+ return { op: "read_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path };
539
+ },
540
+
541
+ /// Write a whole file (parent directories created, an existing file
542
+ /// replaced whole). Exactly one terminal Msg: the `ok` arm — which carries
543
+ /// no payload — or the `err` arm with the reason bytes.
544
+ writeFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M> {
545
+ return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
546
+ },
547
+
548
+ /// A buffered HTTP(S) exchange. Exactly one terminal Msg: the `ok` arm
549
+ /// with `{ status, body }` (one number field, one bytes field — a non-2xx
550
+ /// status is still ok: an HTTP-level error is a delivered response), or
551
+ /// the `err` arm with the reason bytes.
552
+ fetch<M extends Msgish>(spec: FetchSpec, route: FetchRoute<M>): Cmd<M> {
553
+ const names = Object.keys(spec.headers ?? {}).sort();
554
+ return {
555
+ op: "fetch",
556
+ key: route.key ?? "",
557
+ okKind: route.ok,
558
+ errKind: route.err,
559
+ method: spec.method ?? "GET",
560
+ timeoutMs: spec.timeoutMs ?? 0,
561
+ url: spec.url,
562
+ headers: names.map((n) => ({ name: n, value: spec.headers![n] })),
563
+ body: spec.body ?? new Uint8Array(0),
564
+ };
565
+ },
566
+
567
+ /// Put bytes on the system clipboard, fire-and-forget (an over-bound or
568
+ /// refused write is dropped — there is no route to report on).
569
+ clipboardWrite(bytes: Uint8Array): Cmd<never> {
570
+ return { op: "clip_write", bytes };
571
+ },
572
+
573
+ /// Read the system clipboard. Exactly one terminal Msg: the `ok` arm with
574
+ /// the text bytes, or the `err` arm with the reason bytes ("failed",
575
+ /// "rejected").
576
+ clipboardRead<M extends Msgish>(route: RequestRoute<M>): Cmd<M> {
577
+ return { op: "clip_read", key: route.key ?? "", okKind: route.ok, errKind: route.err };
578
+ },
579
+
580
+ /// A keyed one-shot delay: dispatch the named Msg arm once, `ms` from now,
581
+ /// with the fire time (milliseconds) as its single number payload field.
582
+ /// Re-issuing a live key re-arms it from now (the debounce discipline);
583
+ /// `Cmd.cancel(key)` drops it silently.
584
+ delay<M extends Msgish>(key: string, ms: number, msgKind: TimestampKind<M>): Cmd<M> {
585
+ return { op: "delay", key, afterMs: ms, msgKind };
586
+ },
587
+
588
+ /// Run a subprocess as a STREAM: each stdout line dispatches the `line`
589
+ /// arm as it arrives (line mode), or whole stdout buffers to the exit
590
+ /// (`collect: true`); exactly one terminal follows — the `exit` arm on a
591
+ /// clean exit, the `err` arm with the reason bytes on every other end.
592
+ /// The key stays live for the whole stream: `Cmd.cancel(key)` ends the
593
+ /// child mid-stream (err arm "cancelled" — loud on purpose: killing a
594
+ /// process is an observable event), and a spawn whose key is already
595
+ /// streaming is rejected, never replaced — a running subprocess is never
596
+ /// killed implicitly; cancel it first.
597
+ spawn<M extends Msgish>(
598
+ argv: readonly Uint8Array[],
599
+ route: SpawnRoute<M> | SpawnCollectRoute<M>,
600
+ ): Cmd<M> {
601
+ const collect = "collect" in route && route.collect === true;
602
+ return {
603
+ op: "spawn",
604
+ key: route.key ?? "",
605
+ lineKind: collect ? "" : ((route as SpawnRoute<M>).line ?? ""),
606
+ exitKind: route.exit,
607
+ errKind: route.err,
608
+ collect,
609
+ argv,
610
+ stdin: route.stdin ?? new Uint8Array(0),
611
+ };
612
+ },
613
+
614
+ /// Open (or replace — one player is the whole surface) the keyed audio
615
+ /// event stream: resolve the source cascade (local path, then url, cached
616
+ /// and integrity-gated) and start playback. Every playback event
617
+ /// dispatches the `event` arm until `Cmd.audioStop(key)` closes the
618
+ /// stream. Failure is never silent: an unplayable source arrives as a
619
+ /// "failed" event, a refused command as "rejected".
620
+ audioPlay<M extends Msgish>(key: string, source: AudioSource, route: AudioRoute<M>): Cmd<M> {
621
+ return {
622
+ op: "audio_play",
623
+ key,
624
+ eventKind: route.event,
625
+ path: source.path ?? new Uint8Array(0),
626
+ url: source.url ?? new Uint8Array(0),
627
+ cachePath: source.cachePath ?? new Uint8Array(0),
628
+ expectedBytes: source.expectedBytes ?? 0,
629
+ };
630
+ },
631
+
632
+ /// Pause the keyed playback in place (no event echo — the caller
633
+ /// commanded it). A key with no open stream no-ops.
634
+ audioPause(key: string): Cmd<never> {
635
+ return { op: "audio_ctl", key, verb: "pause", value: 0 };
636
+ },
637
+
638
+ /// Resume the keyed playback. A player that can no longer resume reports
639
+ /// one "failed" event on the stream instead of silence.
640
+ audioResume(key: string): Cmd<never> {
641
+ return { op: "audio_ctl", key, verb: "resume", value: 0 };
642
+ },
643
+
644
+ /// Stop the keyed playback and CLOSE its event stream: no events for the
645
+ /// key after this. Stop is the audio stream's cancel.
646
+ audioStop(key: string): Cmd<never> {
647
+ return { op: "audio_ctl", key, verb: "stop", value: 0 };
648
+ },
649
+
650
+ /// Jump the keyed playback to `ms` (the platform clamps to the duration).
651
+ /// No event echo — the next position tick reports from there.
652
+ audioSeek(key: string, ms: number): Cmd<never> {
653
+ return { op: "audio_ctl", key, verb: "seek", value: ms };
654
+ },
655
+
656
+ /// Set playback volume, clamped to 0..1 and remembered across tracks: the
657
+ /// next audioPlay re-applies it.
658
+ audioSetVolume(key: string, volume: number): Cmd<never> {
659
+ return { op: "audio_ctl", key, verb: "volume", value: volume };
660
+ },
661
+
662
+ /// Several commands from one dispatch, performed in order.
663
+ batch<M extends Msgish>(cmds: readonly Cmd<M>[]): Cmd<M> {
664
+ return { op: "batch", cmds };
665
+ },
666
+ };
667
+
668
+ /// An inert subscription descriptor: recurring effects declared FROM the
669
+ /// model. An app that needs them exports `subscriptions(model): Sub<Msg>`;
670
+ /// after every commit the host reconciles the returned set against its
671
+ /// active timers by key (new key or changed interval re-arms; a missing key
672
+ /// cancels). Like Cmd, Sub values are data, legal only in that function's
673
+ /// return path (NS1025).
674
+ export type Sub<M extends Msgish> =
675
+ | { readonly op: "none" }
676
+ | { readonly op: "timer"; readonly key: string; readonly everyMs: number; readonly msgKind: string }
677
+ | { readonly op: "batch"; readonly subs: readonly Sub<M>[] };
678
+
679
+ export const Sub = {
680
+ /// No subscriptions (e.g. everything paused).
681
+ none: { op: "none" } as Sub<never>,
682
+
683
+ /// A repeating timer named by `key`, firing every `everyMs` milliseconds.
684
+ /// Each fire dispatches the named Msg arm with the current time (ms) as
685
+ /// its single number payload field.
686
+ timer<M extends Msgish>(key: string, everyMs: number, msgKind: TimestampKind<M>): Sub<M> {
687
+ return { op: "timer", key, everyMs, msgKind };
688
+ },
689
+
690
+ /// Several subscriptions at once.
691
+ batch<M extends Msgish>(subs: readonly Sub<M>[]): Sub<M> {
692
+ return { op: "batch", subs };
693
+ },
694
+ };