@native-sdk/core 0.5.3 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-sdk/core",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "The TypeScript authoring tier: the app-core subset, its dev-time transpiler to arena-backed Zig, and the SDK module cores import",
5
5
  "repository": {
6
6
  "type": "git",
package/sdk/core.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // return path (NS1017) — they never live in the Model, in a Msg, in a local,
11
11
  // or in a helper.
12
12
  //
13
- // The v2 command set:
13
+ // The v3 command set:
14
14
  //
15
15
  // Cmd.none no effects (what a bare `return model` means)
16
16
  // Cmd.persist() ask the host to persist the committed model
@@ -103,6 +103,128 @@
103
103
  // forget control verbs whose consequences arrive
104
104
  // on the event stream; aimed at a key with no
105
105
  // open stream they no-op.
106
+ // Cmd.videoLoad(key, { surface, path?, url?, autoplay?, loop?, muted? }, { event })
107
+ // open (or replace — one player is the whole
108
+ // surface) the video event stream feeding the
109
+ // media-surface `surface` names (the same
110
+ // model-owned id the markup binds): every
111
+ // playback event dispatches the `event` arm (the
112
+ // seven-field record below) until Cmd.videoStop
113
+ // closes the stream. Pixels never ride the
114
+ // events — decoded frames flow platform-side
115
+ // into the bound surface.
116
+ // Cmd.videoPlay(key) / videoPause(key) / videoStop(key)
117
+ // Cmd.videoSeek(key, ms) / Cmd.videoSetVolume(key, volume)
118
+ // Cmd.videoSetMuted(key, muted) / Cmd.videoSetLoop(key, loop)
119
+ // drive the open playback in place — fire-and-
120
+ // forget control verbs whose consequences arrive
121
+ // on the event stream; aimed at a key with no
122
+ // open stream they no-op.
123
+ // Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event })
124
+ // load an image at runtime by its model-owned
125
+ // numeric ImageId (the id markup binds:
126
+ // <image image="{id}"/>, avatar likewise): the
127
+ // host resolves the source cascade (local path
128
+ // first, then a verified cache entry, then the
129
+ // network), decodes through the platform codec,
130
+ // registers the pixels under the id, and
131
+ // dispatches exactly ONE `event` arm (the
132
+ // five-field record below, the requested id
133
+ // echoed) — state "loaded" with the decoded
134
+ // width/height, or one failure class. One load
135
+ // per id at a time: a duplicate live id
136
+ // dispatches state "rejected".
137
+ // Cmd.imageCancel(id) end the in-flight load under the id, if any
138
+ // — loud, the spawn discipline: the load's
139
+ // event arm delivers state "cancelled" and the
140
+ // id frees for a fresh load once it lands. An
141
+ // id with no live load no-ops.
142
+ // Cmd.imageUnregister(id) free the registry slot under the id: the
143
+ // pixels are released, views referencing the
144
+ // id draw their fallback, and the slot is
145
+ // open for another load. Synchronous registry
146
+ // surgery like registration itself — not an
147
+ // effect, no Msg follows; an id with no
148
+ // registration no-ops. A load IN FLIGHT under
149
+ // the id is untouched: its terminal still
150
+ // registers — cancel the load first to keep
151
+ // the slot free.
152
+ // Cmd.channelOpen(key, { event })
153
+ // open an EXTERNAL-SOURCE CHANNEL under the
154
+ // app's numeric key: the host stages a
155
+ // long-lived, thread-safe posting seam its
156
+ // NATIVE side feeds — embedders and
157
+ // platform-services extensions post bytes
158
+ // from their own threads (sockets, watchers,
159
+ // workers), and each accepted post arrives as
160
+ // one "data" event through the `event` arm
161
+ // (the five-field record below) with the
162
+ // honest back-pressure counters aboard. The
163
+ // TS tier opens, closes, and receives;
164
+ // POSTING is not a TS verb — transpiled cores
165
+ // are single-threaded by design, so the
166
+ // posting handle lives on the native side
167
+ // (`Effects.channelHandle(key)`). One channel
168
+ // per key at a time: a duplicate live key
169
+ // dispatches state "rejected". No timer
170
+ // polling anywhere: the source wakes the
171
+ // loop itself.
172
+ // Cmd.channelClose(key) close the open channel under the key, if
173
+ // any: staged posts flush, exactly one
174
+ // "closed" event (final drop totals)
175
+ // dispatches the event arm, and the key
176
+ // frees. A key with no open channel no-ops.
177
+ // Cmd.ptySpawn(argv, { key?, cols?, rows?, term?, event })
178
+ // open a PSEUDO-TERMINAL SESSION — a spawn
179
+ // with a different transport: run argv on a
180
+ // fresh pty whose initial grid is cols x rows
181
+ // (80x24 by default) and whose TERM is `term`
182
+ // (omitted = the engine's default). Every
183
+ // session event dispatches the `event` arm
184
+ // (the six-field record below): "output"
185
+ // events carry coalesced batches of child
186
+ // output across dispatches — feed them to the
187
+ // terminal emulator — until the exactly-one
188
+ // "exit" terminal (a refused spawn is one
189
+ // "exit" with reason "rejected"; a transport
190
+ // that could not start, one with
191
+ // "spawn_failed"). One session per key at a
192
+ // time, never replaced implicitly — kill it
193
+ // first, the spawn discipline.
194
+ // Cmd.ptyWrite(key, bytes) write bytes toward the session's child —
195
+ // keystrokes and pastes, fire-and-forget: a
196
+ // key with no open session no-ops, and
197
+ // refused payloads count into the exit's
198
+ // droppedWrites, never silence.
199
+ // Cmd.ptyResize(key, cols, rows)
200
+ // push a new grid to the session so the child
201
+ // receives SIGWINCH — fire-and-forget like
202
+ // ptyWrite; a key with no open session
203
+ // no-ops.
204
+ // Cmd.ptyKill(key) terminate the session's child — LOUD, the
205
+ // spawn cancel discipline: the session's one
206
+ // "exit" terminal arrives through its own
207
+ // event arm with reason "cancelled" and the
208
+ // key frees once it lands. A key with no open
209
+ // session no-ops. Sessions are their own
210
+ // family's to end: the string-keyed
211
+ // Cmd.cancel never touches them, the way
212
+ // Cmd.audioStop is audio's close.
213
+ //
214
+ // The window verbs (fire-and-forget, no result Msg — the window's own
215
+ // frame event carries the state):
216
+ //
217
+ // Cmd.showWindow(label) un-hide + activate the window with the
218
+ // declared label — the counterpart to a
219
+ // `close_policy = "hide"` hide and the tray
220
+ // "Open" consequence; also restores a
221
+ // minimized window. An unknown label is a
222
+ // no-op.
223
+ // Cmd.quitApp() graceful terminate, the tray "Quit"
224
+ // consequence: the host quits through the
225
+ // SAME shutdown path a last-window close
226
+ // takes, so the stop hook runs exactly once
227
+ // and a recording session seals its journal.
106
228
  //
107
229
  // The keyed-effect discipline is ONE rule: a keyed effect REPLACES its live
108
230
  // predecessor (the superseded effect's result is dropped — no message), and
@@ -119,8 +241,8 @@
119
241
  // only in that function's return path (NS1025). Sub and the streaming Cmds
120
242
  // are different animals on purpose: a Sub is DECLARED from the model (the
121
243
  // 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.
244
+ // spawn/audioPlay/videoLoad streams are Cmd-INITIATED — imperative opens
245
+ // with a keyed lifecycle the app drives and cancels.
124
246
  //
125
247
  // The factories return plain frozen-shape objects so the same core runs
126
248
  // under node: a dev harness can interpret the `op` tags directly.
@@ -248,17 +370,264 @@ export type AudioEventArm = {
248
370
  };
249
371
 
250
372
  /// The Msg arms an audio event stream may target: arms whose payload is
251
- /// exactly the six AudioEventArm fields.
373
+ /// exactly the six AudioEventArm fields. The `state` check runs BOTH
374
+ /// directions: the `&` constraint holds the arm's states to AudioState,
375
+ /// and the tuple-wrapped reverse check holds AudioState to the arm's
376
+ /// states — a narrower union would silently drop event states the host
377
+ /// emits, so it is refused here, not discovered at runtime.
252
378
  export type AudioEventKind<M extends Msgish> = M extends Msgish
253
379
  ? [Exclude<keyof M, "kind">] extends [keyof AudioEventArm]
254
380
  ? [keyof AudioEventArm] extends [Exclude<keyof M, "kind">]
255
381
  ? M extends Msgish & AudioEventArm
256
- ? M["kind"]
382
+ ? [AudioState] extends [M["state"]]
383
+ ? M["kind"]
384
+ : never
385
+ : never
386
+ : never
387
+ : never
388
+ : never;
389
+
390
+ /// The video event states, the audio vocabulary without spectrum: `loaded`
391
+ /// acknowledges a successful load with the player's duration estimate and
392
+ /// the stream's decoded pixel dimensions; `position` ticks at the
393
+ /// platform's honest cadence (~500ms) while playing; `completed` fires
394
+ /// exactly once when a NON-LOOPING playback reaches its natural end (a
395
+ /// looping playback wraps and never completes); `failed` reports a
396
+ /// load/decode/device failure; `rejected` a command the effects layer
397
+ /// refused (an empty or over-long source, a non-http(s) url, an invalid
398
+ /// surface id).
399
+ export type VideoState = "loaded" | "position" | "completed" | "failed" | "rejected";
400
+
401
+ /// The payload shape of a video event arm — seven fields, matched by NAME
402
+ /// (the AudioEventArm convention). `state` must be a named
403
+ /// string-literal-union alias carrying exactly the five VideoState members
404
+ /// (any declaration order — the host matches members by name).
405
+ /// `positionMs`/`durationMs` are milliseconds; `playing` is the player's
406
+ /// transport state; `buffering` is true while a streamed source is stalled
407
+ /// waiting for network bytes; `width`/`height` are the stream's decoded
408
+ /// pixel dimensions (delivered on "loaded", 0 elsewhere).
409
+ export type VideoEventArm = {
410
+ readonly state: VideoState;
411
+ readonly positionMs: number;
412
+ readonly durationMs: number;
413
+ readonly playing: boolean;
414
+ readonly buffering: boolean;
415
+ readonly width: number;
416
+ readonly height: number;
417
+ };
418
+
419
+ /// The Msg arms a video event stream may target: arms whose payload is
420
+ /// exactly the seven VideoEventArm fields. The `state` check runs BOTH
421
+ /// directions (the AudioEventKind convention): the `&` constraint holds
422
+ /// the arm's states to VideoState, and the tuple-wrapped reverse check
423
+ /// holds VideoState to the arm's states — a narrower union would
424
+ /// silently drop event states the host emits, so it is refused here,
425
+ /// not discovered at runtime.
426
+ export type VideoEventKind<M extends Msgish> = M extends Msgish
427
+ ? [Exclude<keyof M, "kind">] extends [keyof VideoEventArm]
428
+ ? [keyof VideoEventArm] extends [Exclude<keyof M, "kind">]
429
+ ? M extends Msgish & VideoEventArm
430
+ ? [VideoState] extends [M["state"]]
431
+ ? M["kind"]
432
+ : never
433
+ : never
434
+ : never
435
+ : never
436
+ : never;
437
+
438
+ /// The image load result states, mirroring the engine's outcome vocabulary:
439
+ /// "loaded" means the pixels are registered under the requested id (width and
440
+ /// height carry what the codec decoded); every other state is the failure
441
+ /// class — "rejected" (a refused command: invalid id, no source, a duplicate
442
+ /// live id), "not_found" (missing local file, no url), "io_failed" (a local
443
+ /// read failure), "connect_failed"/"tls_failed"/"protocol_failed"/"timed_out"
444
+ /// (the fetch taxonomy), "http_status" (a non-2xx answer; `status` carries
445
+ /// it), "cancelled", "too_large" (source or decoded pixels over budget),
446
+ /// "unsupported" (no platform codec), "decode_failed", "registry_full", and
447
+ /// "alloc_failed" (the host refused the memory the registration needed —
448
+ /// resource exhaustion, not corrupt bytes: the same source may load once
449
+ /// memory frees).
450
+ export type ImageState =
451
+ | "loaded"
452
+ | "rejected"
453
+ | "not_found"
454
+ | "io_failed"
455
+ | "connect_failed"
456
+ | "tls_failed"
457
+ | "protocol_failed"
458
+ | "timed_out"
459
+ | "http_status"
460
+ | "cancelled"
461
+ | "too_large"
462
+ | "unsupported"
463
+ | "decode_failed"
464
+ | "registry_full"
465
+ | "alloc_failed";
466
+
467
+ /// The payload shape of an image result arm — five fields, matched by NAME
468
+ /// (the AudioEventArm convention). `id` is the requested ImageId echoed
469
+ /// verbatim, so two concurrent loads sharing one event arm stay
470
+ /// distinguishable in `update` (an id the wire cannot carry exactly — 0,
471
+ /// negatives, fractions, 2^53 and past — echoes 0, the no-image sentinel:
472
+ /// there is no honest integer to echo); `state` must be a named
473
+ /// string-literal-union alias carrying exactly the fifteen ImageState
474
+ /// members (any declaration order — the host matches members by name);
475
+ /// `width`/`height` are the decoded pixel dimensions ("loaded" only, 0
476
+ /// otherwise); `status` is the HTTP status for url loads that performed an
477
+ /// exchange, 0 when none occurred (local paths, cache hits) — 0 is signal,
478
+ /// not a missing value: a cache hit is a real "loaded" with no exchange
479
+ /// behind it, so apps can distinguish a network load from a cached one.
480
+ export type ImageEventArm = {
481
+ readonly id: number;
482
+ readonly state: ImageState;
483
+ readonly width: number;
484
+ readonly height: number;
485
+ readonly status: number;
486
+ };
487
+
488
+ /// The Msg arms an image load result may target: arms whose payload is
489
+ /// exactly the five ImageEventArm fields. The `state` check runs BOTH
490
+ /// directions (the AudioEventKind convention): the `&` constraint holds
491
+ /// the arm's states to ImageState, and the tuple-wrapped reverse check
492
+ /// holds ImageState to the arm's states — a narrower union would
493
+ /// silently drop result states the host emits, so it is refused here,
494
+ /// not discovered at runtime.
495
+ export type ImageEventKind<M extends Msgish> = M extends Msgish
496
+ ? [Exclude<keyof M, "kind">] extends [keyof ImageEventArm]
497
+ ? [keyof ImageEventArm] extends [Exclude<keyof M, "kind">]
498
+ ? M extends Msgish & ImageEventArm
499
+ ? [ImageState] extends [M["state"]]
500
+ ? M["kind"]
501
+ : never
502
+ : never
503
+ : never
504
+ : never
505
+ : never;
506
+
507
+ /// The external-source channel event states, mirroring the engine's
508
+ /// vocabulary: "data" is one delivered post from the native side's
509
+ /// thread-safe handle; "closed" is the exactly-one terminal
510
+ /// `Cmd.channelClose` produces (final drop totals aboard); "rejected" is
511
+ /// the exactly-one terminal of a refused open (duplicate live key, a
512
+ /// full channel table, an occupied engine key).
513
+ export type ChannelState = "data" | "closed" | "rejected";
514
+
515
+ /// The payload shape of a channel event arm — five fields, matched by
516
+ /// NAME (the AudioEventArm convention). `key` is the channel key echoed
517
+ /// verbatim, so concurrent channels sharing one arm stay
518
+ /// distinguishable (a key the wire cannot carry exactly echoes 0);
519
+ /// `state` must be a named string-literal-union alias carrying exactly
520
+ /// the three ChannelState members (any declaration order — the host
521
+ /// matches members by name); `bytes` is the post's payload ("data"
522
+ /// events only, empty otherwise); `droppedPending`/`droppedTotal` are
523
+ /// the back-pressure counters — posts the native side's handle refused
524
+ /// since the previous delivered event, and over the channel's whole
525
+ /// life. Never silent drops: the counters ride every event.
526
+ export type ChannelEventArm = {
527
+ readonly key: number;
528
+ readonly state: ChannelState;
529
+ readonly bytes: Uint8Array;
530
+ readonly droppedPending: number;
531
+ readonly droppedTotal: number;
532
+ };
533
+
534
+ /// The Msg arms a channel event stream may target: arms whose payload is
535
+ /// exactly the five ChannelEventArm fields. The `state` check runs BOTH
536
+ /// directions (the AudioEventKind convention): the `&` constraint holds
537
+ /// the arm's states to ChannelState, and the tuple-wrapped reverse check
538
+ /// holds ChannelState to the arm's states — a narrower union would
539
+ /// silently drop event states the host emits, so it is refused here,
540
+ /// not discovered at runtime.
541
+ export type ChannelEventKind<M extends Msgish> = M extends Msgish
542
+ ? [Exclude<keyof M, "kind">] extends [keyof ChannelEventArm]
543
+ ? [keyof ChannelEventArm] extends [Exclude<keyof M, "kind">]
544
+ ? M extends Msgish & ChannelEventArm
545
+ ? [ChannelState] extends [M["state"]]
546
+ ? M["kind"]
547
+ : never
257
548
  : never
258
549
  : never
259
550
  : never
260
551
  : never;
261
552
 
553
+ /// `Cmd.channelOpen` routing: every channel event dispatches the `event`
554
+ /// arm (the five-field ChannelEventArm record, matched by field name).
555
+ export interface ChannelRoute<M extends Msgish> {
556
+ readonly event: ChannelEventKind<M>;
557
+ }
558
+
559
+ /// The pty session event states, mirroring the engine's vocabulary:
560
+ /// "output" is one coalesced batch of child output; "exit" is the
561
+ /// exactly-one terminal every session produces — a clean end, a signal,
562
+ /// a `Cmd.ptyKill`, a refused spawn, or a transport that could not
563
+ /// start (the `reason` field tells which).
564
+ export type PtyState = "output" | "exit";
565
+
566
+ /// How a pty session ended, the spawn exit vocabulary: "exited" with
567
+ /// the child's code, "signaled" with the signal, "cancelled" after
568
+ /// `Cmd.ptyKill`, "rejected" for a spawn refused before a child existed
569
+ /// (duplicate live key, full table, bad grid), "spawn_failed" when the
570
+ /// pty or exec could not start.
571
+ export type PtyExitReason = "exited" | "signaled" | "cancelled" | "rejected" | "spawn_failed";
572
+
573
+ /// The payload shape of a pty event arm — seven fields, matched by NAME
574
+ /// (the AudioEventArm convention). `key` is the app's own session key
575
+ /// (the `Cmd.ptySpawn` `key`, or "" when the spawn named none) — two
576
+ /// sessions routing one event arm are told apart by this field. `state`
577
+ /// must be a named string-literal-union alias carrying exactly the two
578
+ /// PtyState members and `reason` one carrying exactly the five
579
+ /// PtyExitReason members (any declaration order — the host matches
580
+ /// members by name); `bytes` is the coalesced output batch ("output"
581
+ /// events only, empty otherwise); `code` is the child's exit code on an
582
+ /// "exited" end, -1 otherwise; `signal` is the fatal signal after a
583
+ /// "signaled" end, else 0; `droppedWrites` counts `Cmd.ptyWrite`
584
+ /// payloads refused over the session's life — zero means every write
585
+ /// reached the child, never a silent drop.
586
+ export type PtyEventArm = {
587
+ readonly key: string;
588
+ readonly state: PtyState;
589
+ readonly bytes: Uint8Array;
590
+ readonly code: number;
591
+ readonly reason: PtyExitReason;
592
+ readonly signal: number;
593
+ readonly droppedWrites: number;
594
+ };
595
+
596
+ /// The Msg arms a pty session may target: arms whose payload is exactly
597
+ /// the six PtyEventArm fields. The `state` and `reason` checks run BOTH
598
+ /// directions (the AudioEventKind convention): the `&` constraint holds
599
+ /// the arm's unions to PtyState/PtyExitReason, and the tuple-wrapped
600
+ /// reverse checks hold them to the arm's — a narrower union would
601
+ /// silently drop events the host emits, so it is refused here, not
602
+ /// discovered at runtime.
603
+ export type PtyEventKind<M extends Msgish> = M extends Msgish
604
+ ? [Exclude<keyof M, "kind">] extends [keyof PtyEventArm]
605
+ ? [keyof PtyEventArm] extends [Exclude<keyof M, "kind">]
606
+ ? M extends Msgish & PtyEventArm
607
+ ? [PtyState] extends [M["state"]]
608
+ ? [PtyExitReason] extends [M["reason"]]
609
+ ? M["kind"]
610
+ : never
611
+ : never
612
+ : never
613
+ : never
614
+ : never
615
+ : never;
616
+
617
+ /// `Cmd.ptySpawn` routing: every session event dispatches the `event`
618
+ /// arm (the six-field PtyEventArm record, matched by field name).
619
+ /// `cols`/`rows` are the initial grid the child observes (80x24 when
620
+ /// omitted); `term` is the TERM the child starts with (omitted = the
621
+ /// engine's default). The optional `key` names the session for
622
+ /// ptyWrite/ptyResize/ptyKill.
623
+ export interface PtyRoute<M extends Msgish> {
624
+ readonly key?: string;
625
+ readonly cols?: number;
626
+ readonly rows?: number;
627
+ readonly term?: string;
628
+ readonly event: PtyEventKind<M>;
629
+ }
630
+
262
631
  /// One field of a host record payload; see hostRecordBytes for the encoding.
263
632
  export type HostScalar = number | boolean | Uint8Array;
264
633
 
@@ -335,6 +704,50 @@ export interface AudioRoute<M extends Msgish> {
335
704
  readonly event: AudioEventKind<M>;
336
705
  }
337
706
 
707
+ /// A `Cmd.videoLoad` source. `surface` is the model-owned media-surface id
708
+ /// the markup binds — the texture channel the decoded frames feed. The
709
+ /// local `path` is tried first; a missing file falls through to `url`
710
+ /// (streamed progressively, playable before the download finishes). At
711
+ /// least one of path/url must be present. `autoplay` (default true) starts
712
+ /// playback as soon as the load lands — false loads paused at position
713
+ /// zero, the poster-frame shape; `loop` wraps from the natural end back to
714
+ /// zero (a looping playback never delivers "completed"); `muted` starts
715
+ /// the audio track muted, independent of the remembered volume.
716
+ export interface VideoSource {
717
+ readonly surface: number;
718
+ readonly path?: Uint8Array;
719
+ readonly url?: Uint8Array;
720
+ readonly autoplay?: boolean;
721
+ readonly loop?: boolean;
722
+ readonly muted?: boolean;
723
+ }
724
+
725
+ /// `Cmd.videoLoad` routing: every playback event dispatches the `event`
726
+ /// arm (the seven-field VideoEventArm record, matched by field name).
727
+ export interface VideoRoute<M extends Msgish> {
728
+ readonly event: VideoEventKind<M>;
729
+ }
730
+
731
+ /// A `Cmd.imageLoad` source: the audio cascade's shape exactly. The local
732
+ /// `path` is tried first; a missing file falls through to `url` (fetched
733
+ /// whole, installed at `cachePath` when given and verified against
734
+ /// `expectedBytes` — 0/omitted means unknown size, existence alone qualifies
735
+ /// a cache entry; omit `cachePath` and the host derives the conventional
736
+ /// content-addressed path when a caches directory is configured). At least
737
+ /// one of path/url must be present.
738
+ export interface ImageSource {
739
+ readonly path?: Uint8Array;
740
+ readonly url?: Uint8Array;
741
+ readonly cachePath?: Uint8Array;
742
+ readonly expectedBytes?: number;
743
+ }
744
+
745
+ /// `Cmd.imageLoad` routing: the ONE terminal result dispatches the `event`
746
+ /// arm (the five-field ImageEventArm record, matched by field name).
747
+ export interface ImageRoute<M extends Msgish> {
748
+ readonly event: ImageEventKind<M>;
749
+ }
750
+
338
751
  /// The closed HTTP verb set of `Cmd.fetch` (wire value = declaration order).
339
752
  export type FetchMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
340
753
 
@@ -431,6 +844,53 @@ export type Cmd<M extends Msgish> =
431
844
  /// Seek position (ms) / volume (0..1); 0 for the value-less verbs.
432
845
  readonly value: number;
433
846
  }
847
+ | {
848
+ readonly op: "video_load";
849
+ readonly key: string;
850
+ readonly eventKind: string;
851
+ readonly surface: number;
852
+ readonly path: Uint8Array;
853
+ readonly url: Uint8Array;
854
+ readonly autoplay: boolean;
855
+ readonly loop: boolean;
856
+ readonly muted: boolean;
857
+ }
858
+ | {
859
+ readonly op: "video_ctl";
860
+ readonly key: string;
861
+ readonly verb: "play" | "pause" | "stop" | "seek" | "volume" | "muted" | "loop";
862
+ /// Seek position (ms) / volume (0..1) / the muted-loop switch
863
+ /// (0 = off, 1 = on); 0 for the value-less verbs.
864
+ readonly value: number;
865
+ }
866
+ | { readonly op: "window_show"; readonly label: string }
867
+ | { readonly op: "quit_app" }
868
+ | {
869
+ readonly op: "image_load";
870
+ readonly id: number;
871
+ readonly eventKind: string;
872
+ readonly path: Uint8Array;
873
+ readonly url: Uint8Array;
874
+ readonly cachePath: Uint8Array;
875
+ readonly expectedBytes: number;
876
+ }
877
+ | { readonly op: "image_cancel"; readonly id: number }
878
+ | { readonly op: "image_unregister"; readonly id: number }
879
+ | { readonly op: "channel_open"; readonly key: number; readonly eventKind: string }
880
+ | { readonly op: "channel_close"; readonly key: number }
881
+ | {
882
+ readonly op: "pty_spawn";
883
+ readonly key: string;
884
+ readonly eventKind: string;
885
+ readonly cols: number;
886
+ readonly rows: number;
887
+ /// "" = the engine's default TERM (the wire never bakes it in).
888
+ readonly term: string;
889
+ readonly argv: readonly Uint8Array[];
890
+ }
891
+ | { readonly op: "pty_write"; readonly key: string; readonly bytes: Uint8Array }
892
+ | { readonly op: "pty_resize"; readonly key: string; readonly cols: number; readonly rows: number }
893
+ | { readonly op: "pty_kill"; readonly key: string }
434
894
  | { readonly op: "batch"; readonly cmds: readonly Cmd<M>[] };
435
895
 
436
896
  /// The wire encoding of a host record payload, byte-identical to what the
@@ -659,6 +1119,247 @@ export const Cmd = {
659
1119
  return { op: "audio_ctl", key, verb: "volume", value: volume };
660
1120
  },
661
1121
 
1122
+ /// Open (or replace — one player is the whole surface) the keyed video
1123
+ /// event stream: claim the media-surface the source names, resolve the
1124
+ /// source cascade (local path, then url) and start playback (autoplay,
1125
+ /// the default). Every playback event dispatches the `event` arm until
1126
+ /// `Cmd.videoStop(key)` closes the stream. Pixels never ride the
1127
+ /// events: decoded frames flow platform-side into the bound surface.
1128
+ /// Failure is never silent: an unplayable source arrives as a "failed"
1129
+ /// event, a refused command as "rejected". Replacing is not stopping:
1130
+ /// a replaced load still delivers the terminal it owes (its failure is
1131
+ /// never silent either), routed to ITS OWN event arm — only
1132
+ /// `Cmd.videoStop` cancels a stream's undelivered answers.
1133
+ videoLoad<M extends Msgish>(key: string, source: VideoSource, route: VideoRoute<M>): Cmd<M> {
1134
+ return {
1135
+ op: "video_load",
1136
+ key,
1137
+ eventKind: route.event,
1138
+ surface: source.surface,
1139
+ path: source.path ?? new Uint8Array(0),
1140
+ url: source.url ?? new Uint8Array(0),
1141
+ autoplay: source.autoplay ?? true,
1142
+ loop: source.loop ?? false,
1143
+ muted: source.muted ?? false,
1144
+ };
1145
+ },
1146
+
1147
+ /// Start or resume the loaded playback — the poster-frame counterpart
1148
+ /// of `autoplay: false`, and un-pause. A key with no open stream
1149
+ /// no-ops; a player that can no longer start reports one "failed"
1150
+ /// event on the stream instead of silence.
1151
+ videoPlay(key: string): Cmd<never> {
1152
+ return { op: "video_ctl", key, verb: "play", value: 0 };
1153
+ },
1154
+
1155
+ /// Pause the keyed playback in place; the surface keeps its last frame
1156
+ /// (no event echo — the caller commanded it). A key with no open
1157
+ /// stream no-ops.
1158
+ videoPause(key: string): Cmd<never> {
1159
+ return { op: "video_ctl", key, verb: "pause", value: 0 };
1160
+ },
1161
+
1162
+ /// Stop the keyed playback, release the surface claim, and CLOSE its
1163
+ /// event stream: no events for the key after this. Stop is the video
1164
+ /// stream's cancel, exactly `Cmd.audioStop`'s discipline.
1165
+ videoStop(key: string): Cmd<never> {
1166
+ return { op: "video_ctl", key, verb: "stop", value: 0 };
1167
+ },
1168
+
1169
+ /// Jump the keyed playback to `ms` (the platform clamps to the
1170
+ /// duration; a paused seek still pushes the sought frame, so scrubbing
1171
+ /// is visible). No event echo — the next position tick reports from
1172
+ /// there.
1173
+ videoSeek(key: string, ms: number): Cmd<never> {
1174
+ return { op: "video_ctl", key, verb: "seek", value: ms };
1175
+ },
1176
+
1177
+ /// Set playback volume, clamped to 0..1 and remembered across loads:
1178
+ /// the next videoLoad re-applies it. Independent of mute.
1179
+ videoSetVolume(key: string, volume: number): Cmd<never> {
1180
+ return { op: "video_ctl", key, verb: "volume", value: volume };
1181
+ },
1182
+
1183
+ /// Mute or unmute the playback's audio track without touching the
1184
+ /// remembered volume (a fresh load's `muted` option is the way to
1185
+ /// start muted).
1186
+ videoSetMuted(key: string, muted: boolean): Cmd<never> {
1187
+ return { op: "video_ctl", key, verb: "muted", value: muted ? 1 : 0 };
1188
+ },
1189
+
1190
+ /// Enable or disable looping on the open playback (a fresh load's
1191
+ /// `loop` option covers the start-looping case). A looping playback
1192
+ /// never delivers "completed".
1193
+ videoSetLoop(key: string, loop: boolean): Cmd<never> {
1194
+ return { op: "video_ctl", key, verb: "loop", value: loop ? 1 : 0 };
1195
+ },
1196
+
1197
+ /// Show the window with the declared `label`: un-hide + activate — the
1198
+ /// counterpart to a `close_policy = "hide"` hide and the tray "Open"
1199
+ /// consequence; also restores a minimized window. Fire-and-forget: no
1200
+ /// result Msg (the window's own frame event carries the state), and an
1201
+ /// unknown label is a no-op. The label is a string literal — window
1202
+ /// labels are declarations.
1203
+ showWindow(label: string): Cmd<never> {
1204
+ return { op: "window_show", label };
1205
+ },
1206
+
1207
+ /// Quit the app for real — the graceful terminate, and the tray "Quit"
1208
+ /// consequence. The host quits through the SAME shutdown path a
1209
+ /// last-window close takes, so the stop hook runs exactly once and a
1210
+ /// recording session seals its journal. Fire-and-forget.
1211
+ quitApp(): Cmd<never> {
1212
+ return { op: "quit_app" };
1213
+ },
1214
+
1215
+ /// Load an image at runtime under the model-owned numeric ImageId your
1216
+ /// markup binds (`<image image="{id}"/>`, `<avatar image="{id}"/>`):
1217
+ /// resolve the source cascade (local path first, then a verified cache
1218
+ /// entry, then the network), decode through the platform codec, register
1219
+ /// the pixels under `id`, and dispatch exactly ONE `event` arm — state
1220
+ /// "loaded" with the decoded width/height, or one failure class, always
1221
+ /// echoing the requested id so concurrent loads sharing the arm stay
1222
+ /// distinguishable. Failure is never silent, and views referencing the
1223
+ /// id repaint on the next
1224
+ /// frame. One load per id at a time: a duplicate live id dispatches state
1225
+ /// "rejected" (finish or re-key instead — ids are model data). Ids are
1226
+ /// positive integers below 2^53 outside the reserved bit-63 namespace;
1227
+ /// 0 is the no-image sentinel and dispatches "rejected".
1228
+ imageLoad<M extends Msgish>(id: number, source: ImageSource, route: ImageRoute<M>): Cmd<M> {
1229
+ return {
1230
+ op: "image_load",
1231
+ id,
1232
+ eventKind: route.event,
1233
+ path: source.path ?? new Uint8Array(0),
1234
+ url: source.url ?? new Uint8Array(0),
1235
+ cachePath: source.cachePath ?? new Uint8Array(0),
1236
+ expectedBytes: source.expectedBytes ?? 0,
1237
+ };
1238
+ },
1239
+
1240
+ /// End the in-flight image load under `id`, if any — LOUDLY: the load's
1241
+ /// one terminal arrives as its own `event` arm with state "cancelled"
1242
+ /// (ending an in-flight load is an observable event, the spawn cancel
1243
+ /// discipline), and the id is free for a fresh load once that terminal
1244
+ /// lands. Aimed at an id with no live load it no-ops (the load it aimed
1245
+ /// at already delivered its terminal). Image loads are keyed by their
1246
+ /// numeric id, so the string-keyed `Cmd.cancel` never touches them —
1247
+ /// this is their cancel, the way `Cmd.audioStop` is audio's.
1248
+ imageCancel(id: number): Cmd<never> {
1249
+ return { op: "image_cancel", id };
1250
+ },
1251
+
1252
+ /// Free the registry slot under `id`: the pixels are released, views
1253
+ /// referencing the id draw their fallback (avatar initials) on the next
1254
+ /// frame, and the slot — one of the registry's 16 — is open for another
1255
+ /// load (the gallery eviction move: unregister the evictee, load the
1256
+ /// newcomer under a fresh id). Like registration itself this is
1257
+ /// synchronous registry surgery, not an effect: no Msg follows, and an
1258
+ /// id with no registration no-ops (whatever it aimed at is already
1259
+ /// gone, `Cmd.imageCancel`'s idle rule). It frees only the CURRENT
1260
+ /// registration — a load IN FLIGHT under the id is untouched and its
1261
+ /// terminal still registers the pixels; to keep the slot free, end the
1262
+ /// load with `Cmd.imageCancel(id)` first.
1263
+ imageUnregister(id: number): Cmd<never> {
1264
+ return { op: "image_unregister", id };
1265
+ },
1266
+
1267
+ /// Open an external-source channel under your numeric `key`: the
1268
+ /// host stages a long-lived, thread-safe posting seam its NATIVE
1269
+ /// side feeds — an embedder or platform-services extension resolves
1270
+ /// the handle (`Effects.channelHandle(key)`) and posts bytes from
1271
+ /// its own threads (a socket reader, a file watcher, a worker), and
1272
+ /// each accepted post arrives as one "data" event through the
1273
+ /// `event` arm, waking the loop itself — no timer polling anywhere.
1274
+ /// POSTING is deliberately not a TS verb: transpiled cores are
1275
+ /// single-threaded, so the TS tier opens, closes, and receives while
1276
+ /// the native side feeds. Back-pressure is part of the contract:
1277
+ /// the native `post` answers a four-way `ChannelHandle.PostResult`
1278
+ /// (accepted / dropped_full / dropped_oversized / closed), so a
1279
+ /// producer tells transient back-pressure from closure, and refused
1280
+ /// posts count into `droppedPending`/`droppedTotal` on the next
1281
+ /// delivered event, never silence. The native post never blocks its
1282
+ /// producer given a conforming host wake — the platform's `wake_fn`
1283
+ /// is contractually a bounded, enqueue-only nudge (see
1284
+ /// `PlatformServices.wake_fn`; every first-party host conforms), and
1285
+ /// the runtime holds no channel lock across it. One channel per key at a
1286
+ /// time — a duplicate live key dispatches state "rejected" — and the
1287
+ /// key shares the engine's effect-key space (a same-key fetch is
1288
+ /// blocked while the channel lives). Keys are positive integers
1289
+ /// below 2^53; the events are journaled at the effect boundary, so
1290
+ /// a recorded session replays the whole stream from the journal and
1291
+ /// never NEEDS the source — under replay the open parks and the
1292
+ /// native handle is inert (every post answers closed). The opening
1293
+ /// update still re-executes, so a native producer launched
1294
+ /// unconditionally really starts and is stopped at its first post;
1295
+ /// one that consults the handle's live() before launching never
1296
+ /// starts, keeping replay fully offline.
1297
+ channelOpen<M extends Msgish>(key: number, route: ChannelRoute<M>): Cmd<M> {
1298
+ return { op: "channel_open", key, eventKind: route.event };
1299
+ },
1300
+
1301
+ /// Close the open channel under `key`: posts stop landing, the
1302
+ /// staged backlog flushes, and exactly one "closed" event (final
1303
+ /// drop totals aboard) dispatches the event arm — then the key is
1304
+ /// free again. A key with no open channel no-ops. Channels are keyed
1305
+ /// by their numeric key, so the string-keyed `Cmd.cancel` never
1306
+ /// touches them — this is their close, the way `Cmd.audioStop` is
1307
+ /// audio's.
1308
+ channelClose(key: number): Cmd<never> {
1309
+ return { op: "channel_close", key };
1310
+ },
1311
+
1312
+ /// Open a pseudo-terminal session — a spawn with a different
1313
+ /// transport: run `argv` on a fresh pty (same argv budgets, same
1314
+ /// child environment policy) whose initial grid is `cols` x `rows`
1315
+ /// (80x24 by default) and whose TERM is `term` (omitted = the
1316
+ /// engine's default). Every session event dispatches the `event` arm:
1317
+ /// "output" events carry coalesced batches of child output across
1318
+ /// dispatches — feed them to the terminal emulator and move on —
1319
+ /// until the exactly-one "exit" terminal retires the session (a
1320
+ /// refused spawn is one "exit" with reason "rejected"; a transport
1321
+ /// that could not start, one with "spawn_failed" — failure is never
1322
+ /// silent). One session per key at a time, never replaced implicitly:
1323
+ /// a running terminal's child is a running subprocess — kill it
1324
+ /// first, the spawn discipline.
1325
+ ptySpawn<M extends Msgish>(argv: readonly Uint8Array[], route: PtyRoute<M>): Cmd<M> {
1326
+ return {
1327
+ op: "pty_spawn",
1328
+ key: route.key ?? "",
1329
+ eventKind: route.event,
1330
+ cols: route.cols ?? 80,
1331
+ rows: route.rows ?? 24,
1332
+ term: route.term ?? "",
1333
+ argv,
1334
+ };
1335
+ },
1336
+
1337
+ /// Write bytes toward the keyed session's child — keystrokes and
1338
+ /// pastes, fire-and-forget: a key with no open session no-ops (the
1339
+ /// exit was already on its way), and refused payloads (over the
1340
+ /// engine's per-write bound, or a child that stopped reading) count
1341
+ /// into the exit event's droppedWrites — never silence.
1342
+ ptyWrite(key: string, bytes: Uint8Array): Cmd<never> {
1343
+ return { op: "pty_write", key, bytes };
1344
+ },
1345
+
1346
+ /// Push a new grid to the keyed session so the child receives
1347
+ /// SIGWINCH — fire-and-forget like ptyWrite; a key with no open
1348
+ /// session no-ops.
1349
+ ptyResize(key: string, cols: number, rows: number): Cmd<never> {
1350
+ return { op: "pty_resize", key, cols, rows };
1351
+ },
1352
+
1353
+ /// Terminate the keyed session's child — LOUD, the spawn cancel
1354
+ /// discipline: the session's one "exit" terminal arrives through its
1355
+ /// own event arm with reason "cancelled" and the key frees once it
1356
+ /// lands. A key with no open session no-ops. Sessions are their own
1357
+ /// family's to end: the string-keyed `Cmd.cancel` never touches them
1358
+ /// — this is their kill, the way `Cmd.audioStop` is audio's close.
1359
+ ptyKill(key: string): Cmd<never> {
1360
+ return { op: "pty_kill", key };
1361
+ },
1362
+
662
1363
  /// Several commands from one dispatch, performed in order.
663
1364
  batch<M extends Msgish>(cmds: readonly Cmd<M>[]): Cmd<M> {
664
1365
  return { op: "batch", cmds };
package/sdk/events.ts CHANGED
@@ -40,15 +40,24 @@
40
40
  export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.ts";
41
41
 
42
42
  /// The scroll-state mirror markup's `on-scroll` matches structurally: a
43
- /// record of exactly these four numeric fields. Offsets and extents are
44
- /// canvas points; `velocity` is points per second while a fling decays.
45
- /// Echo `offset` into the model field bound as the scroll's `value` to
46
- /// keep the region model-driven (setting that field in update scrolls it).
43
+ /// record of exactly these eight numeric fields the TWO-AXIS shape, one
44
+ /// offset/velocity/viewport/content quartet per axis. Offsets and extents
45
+ /// are canvas points; velocities are points per second while a fling
46
+ /// decays. A vertical list carries live `...Y` fields and quiet `...X`
47
+ /// ones (offset 0, content pinned to the viewport width); a horizontal
48
+ /// shelf the reverse. Echo `offsetY` into the model field bound as the
49
+ /// scroll's `value` (and `offsetX` into `value-x` on horizontal-capable
50
+ /// regions) to keep the region model-driven — setting those fields in
51
+ /// update scrolls it.
47
52
  export interface ScrollState {
48
- readonly offset: number;
49
- readonly velocity: number;
50
- readonly viewportExtent: number;
51
- readonly contentExtent: number;
53
+ readonly offsetX: number;
54
+ readonly offsetY: number;
55
+ readonly velocityX: number;
56
+ readonly velocityY: number;
57
+ readonly viewportExtentX: number;
58
+ readonly viewportExtentY: number;
59
+ readonly contentExtentX: number;
60
+ readonly contentExtentY: number;
52
61
  }
53
62
 
54
63
  /// The presented-frame channel's record (`frameMsg(model, frame)`): the