@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/package.json +33 -7
- package/sdk/bytes_text_methods.d.ts +88 -0
- package/sdk/core.ts +694 -0
- package/sdk/events.ts +137 -0
- package/sdk/text.ts +519 -0
- package/README.md +0 -3
- package/index.js +0 -2
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
|
+
};
|