@shenora/react 0.11.0 → 0.12.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.
@@ -2,17 +2,25 @@
2
2
  * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
3
  * MediaSource this browser actually has, and deciding what to fetch next.
4
4
  *
5
- * 🔴 **Everything in this module is PURE, and the split is deliberate.** The DECISIONS live here, where a
6
- * test pins them exactly, and `segmentBinder.ts` holds the imperative half — creating a `SourceBuffer`,
7
- * appending bytes, listening to element events. That is the same division `SegmentGrid` makes on the host
8
- * side, for the same reason.
5
+ * Everything here is PURE: the decisions live in this module and `segmentBinder.ts` holds the
6
+ * imperative half — creating a `SourceBuffer`, appending bytes, listening to element events.
7
+ */
8
+ /**
9
+ * The reserved path segment naming a source by the handle the HOST issued for it:
10
+ * `{routePath}${SEGMENT_REMOTE_PREFIX}{handle}/index.m3u8`.
9
11
  *
10
- * ⚠ **"The imperative half cannot be verified anywhere this repo runs" was true and is no longer.** jsdom
11
- * still has no MediaSource and both real implementations still live on devices — but the binder takes its
12
- * source and its fetch as PARAMETERS, so a fake drives every branch of it. What a fake cannot say is
13
- * whether a real implementation accepts the bytes; that is measured on hardware and recorded in
14
- * `docs/design/media.md`.
12
+ * 🔴 **A page cannot name a remote url, only a handle.** The host's `MediaSourceRegistry` issues one when
13
+ * the app authorises a source, and the route accepts nothing else. Mirrors
14
+ * `SegmentStreamOptions.RemotePrefix`.
15
15
  */
16
+ export declare const SEGMENT_REMOTE_PREFIX = "~remote/";
17
+ /**
18
+ * The manifest url for a handle the app handed this page.
19
+ *
20
+ * ⚠ The handle is opaque and is NOT a url — do not build one from it, and do not log it beside anything
21
+ * that identifies the user. It is a capability: whoever holds it can stream that source.
22
+ */
23
+ export declare function remoteSegmentUrl(routePath: string, handle: string, resource?: string): string;
16
24
  /** One entry in the playlist the host serves. */
17
25
  export interface SegmentEntry {
18
26
  /** Relative to the manifest — `seg12.m4s`. */
@@ -26,8 +34,8 @@ export interface SegmentManifest {
26
34
  * The initialisation segment (`#EXT-X-MAP`), which carries the tracks and their decoder configuration.
27
35
  *
28
36
  * ⚠ **Null means the playlist declared none, and that is not playable through MediaSource** — a fragment
29
- * repeats no configuration, so appending one without this produces a silent decode error. The kit's host
30
- * route always writes it; a foreign playlist may not.
37
+ * repeats no configuration, so appending one without this is a silent decode error. The kit's host route
38
+ * always writes it; a foreign playlist may not.
31
39
  */
32
40
  initUri: string | null;
33
41
  /** The longest a segment may be, from `#EXT-X-TARGETDURATION`. */
@@ -35,11 +43,9 @@ export interface SegmentManifest {
35
43
  segments: SegmentEntry[];
36
44
  }
37
45
  /**
38
- * Parse the subset of HLS the host emits. Deliberately NOT a general playlist parser: this reads what
39
- * `SegmentStream` writes, and anything it does not understand is ignored rather than guessed at.
46
+ * Parse the subset of HLS the host's `SegmentStream` emits — not a general playlist parser.
40
47
  *
41
- * ⚠ Unknown tags are skipped silently BY DESIGN — a playlist gains tags over time and a parser that threw
42
- * on the first one it had not met would break on a host newer than the page.
48
+ * ⚠ An unknown tag is SKIPPED, never an error, so a host newer than the page still parses.
43
49
  */
44
50
  export declare function parseManifest(text: string): SegmentManifest;
45
51
  /** Which MediaSource implementation this browser has, if any. */
@@ -52,20 +58,13 @@ export interface MediaSourceGlobals {
52
58
  /**
53
59
  * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
54
60
  *
55
- * 🔴 **The order is the decision, and it is not "newest wins".** iOS on iPhone has only
56
- * `ManagedMediaSource`; Android has only `MediaSource`. Where BOTH exist the managed one is still preferred,
57
- * because it is the one that tells the page when the platform actually wants data — a page that streams
58
- * regardless is what the managed variant was introduced to stop.
59
- *
60
- * ✅ **Measured rather than assumed** (iPhone 16 Pro simulator, iOS 26, 2026-08-14): `window.MediaSource` is
61
- * `false` there and `ManagedMediaSource` is `true`. So on iOS this is not a preference at all — **a binder
62
- * that only knows `window.MediaSource` does nothing**, and the naming here is what makes one bundle work on
63
- * both shells.
61
+ * 🔴 **iOS has ONLY `ManagedMediaSource` and Android has ONLY `MediaSource`**, so a binder that knows
62
+ * just `window.MediaSource` does nothing at all on iOS. Where both exist the managed one still wins:
63
+ * it is the one that says when the platform actually wants data. (Measured — `docs/design/media.md`.)
64
64
  *
65
- * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND rather than just a constructor.**
66
- * A managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
67
- * outside that window is the thing it exists to prevent. A caller that ignores the kind has not merely
68
- * missed an optimisation.
65
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND and not a constructor.** A
66
+ * managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
67
+ * outside that window is the misuse it exists to detect.
69
68
  */
70
69
  export declare function pickMediaSource(globals: MediaSourceGlobals): MediaSourceKind;
71
70
  /** What {@link nextSegment} needs to know about the element and the buffer. */
@@ -88,29 +87,25 @@ export interface FetchPolicy {
88
87
  /**
89
88
  * The next segment index to fetch, or null for "nothing right now".
90
89
  *
91
- * 🔴 **Every branch here is a decision whose failure is SILENT in a browser**, which is why it is a pure
92
- * function with a test rather than an `if` inside an event handler:
90
+ * 🔴 **Every branch is a decision whose failure is SILENT in a browser:**
93
91
  *
94
- * - **Not streaming → null.** A managed source that is fetched while it said stop is the exact misuse it
95
- * exists to detect, and on iOS the penalty is the platform tearing the source down.
96
- * - **Enough buffered → null.** Fetching further ahead than the policy asks does not make playback smoother;
97
- * it fills a quota, and a `QuotaExceededError` on append arrives as a stall with no obvious cause.
98
- * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it.** Starting from
99
- * "the next index after the last one appended" instead is what breaks seeking: after a jump the last
100
- * append is nowhere near where the user is now.
92
+ * - **Not streaming → null.** On iOS the penalty for fetching past `endstreaming` is the platform
93
+ * tearing the source down.
94
+ * - **Enough buffered → null.** Fetching further ahead fills a quota, and a `QuotaExceededError` on
95
+ * append arrives as a stall with no obvious cause.
96
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it** — never
97
+ * "the next index after the last append", which breaks seeking.
101
98
  */
102
99
  export declare function nextSegment(state: FetchState, policy: FetchPolicy): number | null;
103
100
  /**
104
- * The MIME type to open a `SourceBuffer` with.
101
+ * The MIME type to open a `SourceBuffer` with. The default is H.264 High 4.0 plus AAC-LC.
105
102
  *
106
103
  * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
107
- * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed before the
108
- * init segment arrives. The default is H.264 High 4.0 plus AAC-LC.
104
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed
105
+ * before the init segment arrives.
109
106
  *
110
- * 🔴 **And the default is a DEFAULT rather than a guarantee, because the host copies whatever the source
111
- * already holds** (D76): a segment's picture keeps the profile and level the original encoder chose, and an
112
- * HEVC source arrives as `hvc1` and not `avc1` at all. The family is what an implementation actually checks,
113
- * so an H.264 source of any profile plays through the default — **an HEVC one needs its own string**, e.g.
107
+ * ⚠ The default is not a guarantee: the host copies whatever the source already holds (D76), so an
108
+ * **HEVC source arrives as `hvc1` and needs its own string**, e.g.
114
109
  * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
115
110
  */
116
111
  export declare function segmentMimeType(codecs?: string): string;
@@ -118,16 +113,10 @@ export declare function segmentMimeType(codecs?: string): string;
118
113
  * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
119
114
  * tracks it will actually be fed.
120
115
  *
121
- * 🔴 **The TRACK SET is the part that must be right, and getting it wrong is fatal rather than
122
- * degraded.** Measured against Chromium 151 on the kit's own segments: a video-only init segment
123
- * appended to a buffer opened with the two-track default (`avc1.640028,mp4a.40.2`) fails the FIRST
124
- * append and plays nothing at all — while the same bytes with `avc1.640015` play. A source with no
125
- * soundtrack is ordinary, so a fixed default cannot serve both.
126
- *
127
- * ⚠ **The profile and level, by contrast, are barely checked.** The same measurement fed High 2.1
128
- * content to a buffer opened as Baseline 3.0 (`avc1.42E01E`) and it played. That is why this returns a
129
- * precise string when the configuration is there to read and a family default when it is not: precision
130
- * where it is free, and never a guess about which tracks exist.
116
+ * 🔴 **The TRACK SET must be right, and getting it wrong is fatal rather than degraded**: a video-only
117
+ * init segment appended to a buffer opened with the two-track default fails the FIRST append and plays
118
+ * nothing. A source with no soundtrack is ordinary, so no fixed default serves both. (The profile and
119
+ * level, by contrast, are barely checked — `docs/design/media.md` has the measurements.)
131
120
  *
132
121
  * @param init The bytes of the `#EXT-X-MAP` segment.
133
122
  * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
@@ -2,23 +2,32 @@
2
2
  * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
3
  * MediaSource this browser actually has, and deciding what to fetch next.
4
4
  *
5
- * 🔴 **Everything in this module is PURE, and the split is deliberate.** The DECISIONS live here, where a
6
- * test pins them exactly, and `segmentBinder.ts` holds the imperative half — creating a `SourceBuffer`,
7
- * appending bytes, listening to element events. That is the same division `SegmentGrid` makes on the host
8
- * side, for the same reason.
5
+ * Everything here is PURE: the decisions live in this module and `segmentBinder.ts` holds the
6
+ * imperative half — creating a `SourceBuffer`, appending bytes, listening to element events.
7
+ */
8
+ /**
9
+ * The reserved path segment naming a source by the handle the HOST issued for it:
10
+ * `{routePath}${SEGMENT_REMOTE_PREFIX}{handle}/index.m3u8`.
11
+ *
12
+ * 🔴 **A page cannot name a remote url, only a handle.** The host's `MediaSourceRegistry` issues one when
13
+ * the app authorises a source, and the route accepts nothing else. Mirrors
14
+ * `SegmentStreamOptions.RemotePrefix`.
15
+ */
16
+ export const SEGMENT_REMOTE_PREFIX = '~remote/';
17
+ /**
18
+ * The manifest url for a handle the app handed this page.
9
19
  *
10
- * ⚠ **"The imperative half cannot be verified anywhere this repo runs" was true and is no longer.** jsdom
11
- * still has no MediaSource and both real implementations still live on devices — but the binder takes its
12
- * source and its fetch as PARAMETERS, so a fake drives every branch of it. What a fake cannot say is
13
- * whether a real implementation accepts the bytes; that is measured on hardware and recorded in
14
- * `docs/design/media.md`.
20
+ * ⚠ The handle is opaque and is NOT a url — do not build one from it, and do not log it beside anything
21
+ * that identifies the user. It is a capability: whoever holds it can stream that source.
15
22
  */
23
+ export function remoteSegmentUrl(routePath, handle, resource = 'index.m3u8') {
24
+ const base = routePath.endsWith('/') ? routePath : `${routePath}/`;
25
+ return `${base}${SEGMENT_REMOTE_PREFIX}${encodeURIComponent(handle)}/${resource}`;
26
+ }
16
27
  /**
17
- * Parse the subset of HLS the host emits. Deliberately NOT a general playlist parser: this reads what
18
- * `SegmentStream` writes, and anything it does not understand is ignored rather than guessed at.
28
+ * Parse the subset of HLS the host's `SegmentStream` emits — not a general playlist parser.
19
29
  *
20
- * ⚠ Unknown tags are skipped silently BY DESIGN — a playlist gains tags over time and a parser that threw
21
- * on the first one it had not met would break on a host newer than the page.
30
+ * ⚠ An unknown tag is SKIPPED, never an error, so a host newer than the page still parses.
22
31
  */
23
32
  export function parseManifest(text) {
24
33
  const segments = [];
@@ -54,20 +63,13 @@ export function parseManifest(text) {
54
63
  /**
55
64
  * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
56
65
  *
57
- * 🔴 **The order is the decision, and it is not "newest wins".** iOS on iPhone has only
58
- * `ManagedMediaSource`; Android has only `MediaSource`. Where BOTH exist the managed one is still preferred,
59
- * because it is the one that tells the page when the platform actually wants data — a page that streams
60
- * regardless is what the managed variant was introduced to stop.
66
+ * 🔴 **iOS has ONLY `ManagedMediaSource` and Android has ONLY `MediaSource`**, so a binder that knows
67
+ * just `window.MediaSource` does nothing at all on iOS. Where both exist the managed one still wins:
68
+ * it is the one that says when the platform actually wants data. (Measured — `docs/design/media.md`.)
61
69
  *
62
- * ✅ **Measured rather than assumed** (iPhone 16 Pro simulator, iOS 26, 2026-08-14): `window.MediaSource` is
63
- * `false` there and `ManagedMediaSource` is `true`. So on iOS this is not a preference at all — **a binder
64
- * that only knows `window.MediaSource` does nothing**, and the naming here is what makes one bundle work on
65
- * both shells.
66
- *
67
- * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND rather than just a constructor.**
68
- * A managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
69
- * outside that window is the thing it exists to prevent. A caller that ignores the kind has not merely
70
- * missed an optimisation.
70
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND and not a constructor.** A
71
+ * managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
72
+ * outside that window is the misuse it exists to detect.
71
73
  */
72
74
  export function pickMediaSource(globals) {
73
75
  if (typeof globals.ManagedMediaSource === 'function')
@@ -79,16 +81,14 @@ export function pickMediaSource(globals) {
79
81
  /**
80
82
  * The next segment index to fetch, or null for "nothing right now".
81
83
  *
82
- * 🔴 **Every branch here is a decision whose failure is SILENT in a browser**, which is why it is a pure
83
- * function with a test rather than an `if` inside an event handler:
84
+ * 🔴 **Every branch is a decision whose failure is SILENT in a browser:**
84
85
  *
85
- * - **Not streaming → null.** A managed source that is fetched while it said stop is the exact misuse it
86
- * exists to detect, and on iOS the penalty is the platform tearing the source down.
87
- * - **Enough buffered → null.** Fetching further ahead than the policy asks does not make playback smoother;
88
- * it fills a quota, and a `QuotaExceededError` on append arrives as a stall with no obvious cause.
89
- * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it.** Starting from
90
- * "the next index after the last one appended" instead is what breaks seeking: after a jump the last
91
- * append is nowhere near where the user is now.
86
+ * - **Not streaming → null.** On iOS the penalty for fetching past `endstreaming` is the platform
87
+ * tearing the source down.
88
+ * - **Enough buffered → null.** Fetching further ahead fills a quota, and a `QuotaExceededError` on
89
+ * append arrives as a stall with no obvious cause.
90
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it** — never
91
+ * "the next index after the last append", which breaks seeking.
92
92
  */
93
93
  export function nextSegment(state, policy) {
94
94
  if (!state.streaming)
@@ -97,8 +97,8 @@ export function nextSegment(state, policy) {
97
97
  return null;
98
98
  if (policy.segments.length === 0)
99
99
  return null;
100
- // Walk the playlist's own durations rather than assuming a fixed grid: the LAST segment is short, so
101
- // dividing by the target duration puts the tail index past the end.
100
+ // Walk the playlist's own durations rather than assuming a fixed grid: the LAST segment is short,
101
+ // so dividing by the target duration puts the tail index past the end.
102
102
  let at = 0;
103
103
  let index = 0;
104
104
  for (; index < policy.segments.length; index++) {
@@ -114,16 +114,14 @@ export function nextSegment(state, policy) {
114
114
  return null;
115
115
  }
116
116
  /**
117
- * The MIME type to open a `SourceBuffer` with.
117
+ * The MIME type to open a `SourceBuffer` with. The default is H.264 High 4.0 plus AAC-LC.
118
118
  *
119
119
  * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
120
- * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed before the
121
- * init segment arrives. The default is H.264 High 4.0 plus AAC-LC.
120
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed
121
+ * before the init segment arrives.
122
122
  *
123
- * 🔴 **And the default is a DEFAULT rather than a guarantee, because the host copies whatever the source
124
- * already holds** (D76): a segment's picture keeps the profile and level the original encoder chose, and an
125
- * HEVC source arrives as `hvc1` and not `avc1` at all. The family is what an implementation actually checks,
126
- * so an H.264 source of any profile plays through the default — **an HEVC one needs its own string**, e.g.
123
+ * ⚠ The default is not a guarantee: the host copies whatever the source already holds (D76), so an
124
+ * **HEVC source arrives as `hvc1` and needs its own string**, e.g.
127
125
  * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
128
126
  */
129
127
  export function segmentMimeType(codecs = 'avc1.640028,mp4a.40.2') {
@@ -133,16 +131,10 @@ export function segmentMimeType(codecs = 'avc1.640028,mp4a.40.2') {
133
131
  * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
134
132
  * tracks it will actually be fed.
135
133
  *
136
- * 🔴 **The TRACK SET is the part that must be right, and getting it wrong is fatal rather than
137
- * degraded.** Measured against Chromium 151 on the kit's own segments: a video-only init segment
138
- * appended to a buffer opened with the two-track default (`avc1.640028,mp4a.40.2`) fails the FIRST
139
- * append and plays nothing at all — while the same bytes with `avc1.640015` play. A source with no
140
- * soundtrack is ordinary, so a fixed default cannot serve both.
141
- *
142
- * ⚠ **The profile and level, by contrast, are barely checked.** The same measurement fed High 2.1
143
- * content to a buffer opened as Baseline 3.0 (`avc1.42E01E`) and it played. That is why this returns a
144
- * precise string when the configuration is there to read and a family default when it is not: precision
145
- * where it is free, and never a guess about which tracks exist.
134
+ * 🔴 **The TRACK SET must be right, and getting it wrong is fatal rather than degraded**: a video-only
135
+ * init segment appended to a buffer opened with the two-track default fails the FIRST append and plays
136
+ * nothing. A source with no soundtrack is ordinary, so no fixed default serves both. (The profile and
137
+ * level, by contrast, are barely checked — `docs/design/media.md` has the measurements.)
146
138
  *
147
139
  * @param init The bytes of the `#EXT-X-MAP` segment.
148
140
  * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
@@ -151,8 +143,8 @@ export function segmentMimeType(codecs = 'avc1.640028,mp4a.40.2') {
151
143
  export function codecsFromInitSegment(init) {
152
144
  const view = new DataView(init.buffer, init.byteOffset, init.byteLength);
153
145
  const codecs = [];
154
- /** Bytes are read through the view rather than by index: `noUncheckedIndexedAccess` types `init[i]`
155
- * as possibly-undefined, and a `!` on every read would hide the one that IS out of range. */
146
+ /** Read through the view, not by index: a `!` on every `init[i]` would hide the one that IS out of
147
+ * range, where `getUint8` throws. */
156
148
  const u8 = (at) => view.getUint8(at);
157
149
  const fourcc = (at) => String.fromCharCode(u8(at), u8(at + 1), u8(at + 2), u8(at + 3));
158
150
  const hex2 = (n) => n.toString(16).padStart(2, '0');
@@ -178,9 +170,8 @@ export function codecsFromInitSegment(init) {
178
170
  };
179
171
  /** The `avcC`/`hvcC`/`esds` inside a sample entry, whose own fields come first. */
180
172
  const configuration = (format, start, end) => {
181
- // Measured against the host's own init segment: an `avc1` entry holds 132 bytes of content and a
182
- // 54-byte `avcC`, so the child boxes start 78 in — the 8-byte SampleEntry base (6 reserved + a data
183
- // reference index) plus VisualSampleEntry's 70. An `mp4a` entry is that base plus 20.
173
+ // The child boxes start after the sample entry's own fields: the 8-byte SampleEntry base
174
+ // (6 reserved + a data reference index) plus VisualSampleEntry's 70, or plus 20 for `mp4a`.
184
175
  const visual = format === 'avc1' || format === 'avc3' || format === 'hvc1' || format === 'hev1';
185
176
  const at = start + (visual ? 78 : 28);
186
177
  let derived = '';
@@ -206,8 +197,8 @@ export function codecsFromInitSegment(init) {
206
197
  `${tier ? 'H' : 'L'}${level}`;
207
198
  }
208
199
  else if (type === 'esds' && s + 21 <= end) {
209
- // The object type indication, then the audio object type from the DecoderSpecificInfo's first
210
- // 5 bits. Only AAC is ever copied here, so the walk stays a search rather than a full parser.
200
+ // The audio object type, from the DecoderSpecificInfo's first 5 bits. Only AAC is ever copied
201
+ // here, so the walk stays a search rather than a full descriptor parser.
211
202
  for (let p = s; p + 2 < end; p++) {
212
203
  if (u8(p) === 0x04 && u8(p + 2) === 0x40) {
213
204
  derived = `mp4a.40.${u8(p + 3) || 2}`;
package/dist/store.d.ts CHANGED
@@ -17,12 +17,9 @@ export interface ShenoraStoreIo<TState = unknown> {
17
17
  * action can fully decide by itself. The host stays authoritative for everything a `snapshot`/`on`
18
18
  * reducer covers; this is for state the ACTION already knows and the host would only echo back.
19
19
  *
20
- * 🔴 **Reach for it only when the host has no event to tell you.** This used to be documented with
21
- * the kit's own `clearFinished` doing an optimistic prune, and that example was WITHDRAWN: the
22
- * moment `REQUEST_REMOVED` existed, the local prune became a second thing deciding which rows are
23
- * gone, able to disagree with the host about it. If the host emits an event for the change, let the
24
- * reducer own it — an optimistic path beside a wire event is a divergence waiting to happen, not a
25
- * latency win.
20
+ * 🔴 **Reach for it only when the host has no event to tell you.** If one exists, let the reducer
21
+ * own it: an optimistic path beside a wire event is a second thing deciding the same state, and it
22
+ * can disagree with the host.
26
23
  */
27
24
  setState: (reduce: (state: TState) => TState) => void;
28
25
  }
@@ -34,15 +31,16 @@ export interface ShenoraStoreSnapshot<TState> {
34
31
  /** Fold the response into state. */
35
32
  apply: (state: TState, data: unknown) => TState;
36
33
  }
34
+ /** Inputs for {@link createShenoraStore}. */
37
35
  export interface ShenoraStoreOptions<TState, TActions> {
38
36
  /** State before anything has arrived. */
39
37
  initial: TState;
40
38
  /**
41
39
  * Load the CURRENT state when the first component subscribes.
42
40
  *
43
- * Not optional in spirit, even though it is in the type: a component that mounts while work is
44
- * already in flight has MISSED the events, and a stream cannot be replayed. Snapshot-then-deltas
45
- * is the contract; deltas alone silently work only for whoever was watching from the start.
41
+ * ⚠ Optional in the type only. A component that mounts while work is already in flight has MISSED
42
+ * those events and a stream cannot be replayed, so deltas alone work silently only for whoever was
43
+ * watching from the start.
46
44
  */
47
45
  snapshot?: ShenoraStoreSnapshot<TState>;
48
46
  /** Event type (within this module) → PURE reducer over state. */
@@ -75,28 +73,19 @@ export interface ShenoraStore<TState, TActions> {
75
73
  reset: () => void;
76
74
  }
77
75
  /**
78
- * A store fed by one module's host event stream, shared by every component that reads it.
79
- *
80
- * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
81
- * existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
82
- * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
83
- * progress strip want the same live state, and without a shared store each re-implements the wiring,
84
- * each opens its own subscription, and each starts empty.
76
+ * A store fed by one module's host event stream, shared by every component that reads it — for the
77
+ * status- and progress-driven UI that is inherently many-watchers, where a full panel and a compact
78
+ * progress strip want the same live state.
85
79
  *
86
80
  * What it guarantees:
87
81
  * - **One subscription per event type, however many components read it.** Mounting N components does
88
82
  * not open N subscriptions; unmounting the last one tears them down.
89
83
  * - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
90
- * subscription. This is the part that cannot be retrofitted by subscribing harder.
91
- * - **No state library.** Built on React's `useSyncExternalStore`, which exists for exactly this and
92
- * is tearing-free under concurrent rendering. The kit imposes no store dependency (D13's spirit —
93
- * apps bring their own); every sibling reached for the same one, and baking that in would have been
94
- * solving their stack rather than their problem.
95
- * - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
96
- * testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
97
- *
98
- * Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
99
- * shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
84
+ * subscription.
85
+ * - **No state library.** Built on React's `useSyncExternalStore`, so it is tearing-free under
86
+ * concurrent rendering and imposes no store dependency on the app.
87
+ * - **Reducers are PURE and isolated**: state + payload in, state out, so a store is testable with no
88
+ * bridge and a throwing reducer is reported rather than corrupting shared state.
100
89
  *
101
90
  * @example
102
91
  * const useDeploy = createShenoraStore('DEPLOY', {
package/dist/store.js CHANGED
@@ -1,16 +1,12 @@
1
1
  import { useCallback, useDebugValue, useRef, useSyncExternalStore } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
- /** Inputs for {@link createShenoraStore}. */
5
4
  /**
6
5
  * Is the fresh selector result the same VALUE as the previous one, for re-render purposes?
7
6
  *
8
7
  * `Object.is` plus ONE level of own-key comparison. The shallow step is what lets an inline selector
9
8
  * that derives a new object work — `s => ({ count: s.lines.length })` builds a different object every
10
- * call, and without this every call would look like a change and loop.
11
- *
12
- * ⚠ Deliberately one level. Deep equality would make an unchanged NESTED value hide a changed outer
13
- * one only by cost, and a store whose selectors need deep comparison is selecting too much.
9
+ * call, and without it every call would look like a change and loop.
14
10
  */
15
11
  function equivalent(a, b) {
16
12
  if (Object.is(a, b))
@@ -28,28 +24,19 @@ function equivalent(a, b) {
28
24
  && Object.is(a[k], b[k]));
29
25
  }
30
26
  /**
31
- * A store fed by one module's host event stream, shared by every component that reads it.
32
- *
33
- * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
34
- * existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
35
- * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
36
- * progress strip want the same live state, and without a shared store each re-implements the wiring,
37
- * each opens its own subscription, and each starts empty.
27
+ * A store fed by one module's host event stream, shared by every component that reads it — for the
28
+ * status- and progress-driven UI that is inherently many-watchers, where a full panel and a compact
29
+ * progress strip want the same live state.
38
30
  *
39
31
  * What it guarantees:
40
32
  * - **One subscription per event type, however many components read it.** Mounting N components does
41
33
  * not open N subscriptions; unmounting the last one tears them down.
42
34
  * - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
43
- * subscription. This is the part that cannot be retrofitted by subscribing harder.
44
- * - **No state library.** Built on React's `useSyncExternalStore`, which exists for exactly this and
45
- * is tearing-free under concurrent rendering. The kit imposes no store dependency (D13's spirit —
46
- * apps bring their own); every sibling reached for the same one, and baking that in would have been
47
- * solving their stack rather than their problem.
48
- * - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
49
- * testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
50
- *
51
- * Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
52
- * shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
35
+ * subscription.
36
+ * - **No state library.** Built on React's `useSyncExternalStore`, so it is tearing-free under
37
+ * concurrent rendering and imposes no store dependency on the app.
38
+ * - **Reducers are PURE and isolated**: state + payload in, state out, so a store is testable with no
39
+ * bridge and a throwing reducer is reported rather than corrupting shared state.
53
40
  *
54
41
  * @example
55
42
  * const useDeploy = createShenoraStore('DEPLOY', {
@@ -73,8 +60,8 @@ export function createShenoraStore(module, options) {
73
60
  let state = initial;
74
61
  let snapshotLoaded = false;
75
62
  // Bumped on detach. A snapshot response from an earlier subscriber epoch resolving late must be
76
- // DROPPED: the current epoch has (or will have) its own, fresher request, and applying the old
77
- // body after the new one is a lost update wearing a success path.
63
+ // DROPPED — the current epoch has its own fresher request, and applying the old body over the new
64
+ // one is a lost update wearing a success path.
78
65
  let snapshotEpoch = 0;
79
66
  const listeners = new Set();
80
67
  let unsubscribes = [];
@@ -95,16 +82,15 @@ export function createShenoraStore(module, options) {
95
82
  setState(reduce(state, event.payload, event));
96
83
  }
97
84
  catch (error) {
98
- // A throwing reducer must not corrupt shared state or break the other subscribers — the same
99
- // guarded-callback rule the host applies to app code (Shenora.AppCallback).
85
+ // A throwing reducer must not corrupt shared state or break the other subscribers.
100
86
  report(error, { module, type });
101
87
  }
102
88
  };
103
89
  const loadSnapshot = () => {
104
90
  if (!snapshot || snapshotLoaded)
105
91
  return;
106
- snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick must not
107
- // both fire the request (React StrictMode double-invokes effects, which is precisely this case).
92
+ snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick (React
93
+ // StrictMode double-invokes effects) must not both fire the request.
108
94
  const epoch = snapshotEpoch;
109
95
  bridge()
110
96
  .invoke(module, snapshot.type, { payload: snapshot.payload, scope })
@@ -120,8 +106,8 @@ export function createShenoraStore(module, options) {
120
106
  }, (error) => {
121
107
  if (epoch !== snapshotEpoch)
122
108
  return;
123
- // Allow a later retry: a snapshot that failed because the host was not ready yet should not
124
- // leave the store permanently empty for the rest of the session.
109
+ // Allow a later retry: a snapshot that failed because the host was not ready yet must not
110
+ // leave the store permanently empty.
125
111
  snapshotLoaded = false;
126
112
  report(error, { module, type: snapshot.type });
127
113
  });
@@ -134,17 +120,15 @@ export function createShenoraStore(module, options) {
134
120
  for (const off of unsubscribes)
135
121
  off();
136
122
  unsubscribes = [];
137
- // The next mount must RE-LOAD: with no bus subscription live, everything the host emits from
138
- // here on is missed, so the snapshot the flag was guarding is stale the moment this returns.
139
- // The flag exists to dedupe same-tick double-mounts (StrictMode), not to span subscriber epochs —
140
- // and the epoch bump orphans any request still in flight, so its late answer cannot clobber the
141
- // next epoch's fresher one.
123
+ // The next mount must RE-LOAD: with no bus subscription live, everything the host emits from here
124
+ // on is missed, so the snapshot the flag was guarding is stale the moment this returns. The epoch
125
+ // bump orphans any request still in flight, so its late answer cannot clobber the next one.
142
126
  snapshotLoaded = false;
143
127
  snapshotEpoch++;
144
128
  };
145
129
  const subscribe = (listener) => {
146
- // ONE subscription per event type for the whole store — the property that makes this worth
147
- // existing. The first listener attaches; the last one to leave detaches.
130
+ // ONE subscription per event type for the whole store: the first listener attaches, the last one
131
+ // to leave detaches.
148
132
  if (listeners.size === 0)
149
133
  attach();
150
134
  listeners.add(listener);
@@ -164,26 +148,14 @@ export function createShenoraStore(module, options) {
164
148
  /**
165
149
  * Subscribe to the store, optionally through a selector.
166
150
  *
167
- * 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent.** It used
168
- * to be memoized against STATE identity alone, which looked like the careful thing and was wrong: a
169
- * selector whose closure changed while the state did not returned the PREVIOUS selector's value. A
170
- * list row doing `useShenoraRequests(s => s.byId[id])` whose `id` prop changes — virtualised reuse, a
171
- * route change — rendered the previous row's data until some unrelated event replaced the state.
172
- *
173
- * ⚠ <b>Two requirements pull against each other and both are met by comparing RESULTS rather than
174
- * inputs.</b> `useSyncExternalStore` re-renders whenever `getSnapshot` returns something new by
175
- * `Object.is`, so:
176
- * <list type="bullet">
177
- * <item>keying the cache on STATE alone gives stale values when the selector changes — the bug above;</item>
178
- * <item>keying it on the SELECTOR too loops forever for an inline selector that derives a new object
179
- * (`s => ({ count: s.lines.length })`) — a fresh identity each render, a fresh object each call.
180
- * zustand v5 has exactly that behaviour and answers it with an opt-in `useShallow`.</item>
181
- * </list>
182
- * Comparing the RESULT shallowly gives both: a derived object that is field-for-field the same reuses
183
- * the previous reference and does not re-render, while a genuinely different row does.
151
+ * 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent** — never
152
+ * cached against the state or the selector, both of which fail silently. Keying on STATE alone
153
+ * returns the PREVIOUS selector's value when the closure changes (a list row doing
154
+ * `s => s.byId[id]` whose `id` prop changes renders the previous row's data); keying on the SELECTOR
155
+ * too loops forever for an inline selector that derives a new object (`s => ({ n: s.lines.length })`).
184
156
  */
185
157
  function useStore(selector) {
186
- // The last result handed to React. Reused whenever the fresh one is equivalent, which is what keeps
158
+ // The last result handed to React, reused whenever the fresh one is equivalent — what keeps
187
159
  // getSnapshot stable without pinning it to an input that can go stale.
188
160
  const previous = useRef(null);
189
161
  const getSelected = useCallback(() => {
@@ -10,14 +10,9 @@ export interface ShenoraTransport {
10
10
  subscribe(listener: (message: string) => void): () => void;
11
11
  }
12
12
  /**
13
- * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
14
- * `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and
15
- * callers should fall back to browser-only behavior.
16
- *
17
- * It used to test WebView2 alone, which answered FALSE on the MAUI shell: an app would have
18
- * concluded it was in a plain browser tab while a perfectly good host sat on the other side of the
19
- * channel. Widened when the second shell arrived — the question this function is asked is "is there
20
- * a host", never "is it WebView2".
13
+ * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
14
+ * when a transport to the host exists. In a plain browser this is false and callers should fall back to
15
+ * browser-only behavior. The question is "is there a host", never "is it WebView2".
21
16
  */
22
17
  export declare function isShenoraAvailable(): boolean;
23
18
  /** The WebView2 postMessage transport, or null outside a WebView2 host. */
@@ -25,20 +20,16 @@ export declare function createWebView2Transport(): ShenoraTransport | null;
25
20
  /**
26
21
  * The MAUI `HybridWebView` transport, or null outside a MAUI host.
27
22
  *
28
- * Asymmetric by the platform's design, which is the only thing interesting here: sending goes
29
- * through `window.HybridWebView.SendRawMessage`, while receiving is a `HybridWebViewMessageReceived`
30
- * CustomEvent dispatched on `window`. Both directions carry the same JSON envelopes the desktop
31
- * shell speaks — the host side is `Shenora.Maui.MauiIpcBridge`, and the envelope itself never
32
- * changed, which is the whole point of the transport seam (D16).
23
+ * Asymmetric by the platform's design: sending goes through `window.HybridWebView.SendRawMessage`,
24
+ * while receiving is a `HybridWebViewMessageReceived` CustomEvent dispatched on `window`. Both
25
+ * directions carry the same JSON envelopes the desktop shell speaks; the host side is
26
+ * `Shenora.Maui.MauiIpcBridge`.
33
27
  */
34
28
  export declare function createHybridWebViewTransport(): ShenoraTransport | null;
35
29
  /**
36
30
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
37
31
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
38
- * desktop shell and the MAUI shell without knowing which one it is.
39
- *
40
- * WebView2 is probed FIRST for no deeper reason than that it is the older shell and a page can only
41
- * ever be in one of them; if both objects were somehow present, preferring the one the desktop host
42
- * injects keeps existing behaviour byte-identical.
32
+ * desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
33
+ * them; WebView2 wins if both objects are somehow present.
43
34
  */
44
35
  export declare function createHostTransport(): ShenoraTransport | null;