@shenora/react 0.10.0 → 0.11.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.
@@ -0,0 +1,192 @@
1
+ import { useEffect } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { eventBus as defaultEventBus } from './eventBus.js';
4
+ /**
5
+ * The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host (the
6
+ * containment/module boundary every media delivery path shares — `MediaAccessOptions`, D71), which
7
+ * defaults to the same string — change one and you must change the other.
8
+ *
9
+ * ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), beside the handshake's bare
10
+ * `SHENORA`. It exists so your app stays free to own a module called plainly `MEDIA`.
11
+ */
12
+ export const MEDIA_PLAYER_MODULE = 'SHENORA.MEDIA';
13
+ /**
14
+ * Commands the host sends. **A wire contract**: these strings are duplicated in C# as
15
+ * `MediaPlayerEvents`, and the two halves agree by string or not at all.
16
+ */
17
+ export const MediaPlayerCommands = {
18
+ load: 'PLAYER_LOAD',
19
+ play: 'PLAYER_PLAY',
20
+ pause: 'PLAYER_PAUSE',
21
+ seek: 'PLAYER_SEEK',
22
+ rate: 'PLAYER_RATE',
23
+ unload: 'PLAYER_UNLOAD',
24
+ };
25
+ /** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
26
+ export const MEDIA_PLAYER_REPORT = 'PLAYER_REPORT';
27
+ /**
28
+ * Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
29
+ * and .NET owns the lifecycle (D58).
30
+ *
31
+ * ```tsx
32
+ * const ref = useRef<HTMLVideoElement>(null);
33
+ * useMediaPlayer(ref);
34
+ * return <video ref={ref} playsInline />;
35
+ * ```
36
+ *
37
+ * **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
38
+ * calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
39
+ * it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
40
+ * at all, which needs a probe and a device capability query).
41
+ *
42
+ * ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
43
+ * (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
44
+ * registered by the media feature itself. If you wrote that route by hand against a build from before
45
+ * 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
46
+ *
47
+ * ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
48
+ * object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
49
+ * the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
50
+ * the component so the hook remounts with it.
51
+ *
52
+ * ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
53
+ * would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
54
+ * you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
55
+ * actually redraw.
56
+ *
57
+ * ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
58
+ * the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
59
+ * hears about it rather than silently believing playback started.
60
+ */
61
+ export function useMediaPlayer(ref, options = {}) {
62
+ const { module = MEDIA_PLAYER_MODULE, bridge, eventBus = defaultEventBus } = options;
63
+ useEffect(() => {
64
+ const element = ref.current;
65
+ if (!element)
66
+ return;
67
+ const link = bridge ?? getBridge();
68
+ // The element is the only clock: the host asks IT for position rather than tracking its own, so a
69
+ // report always carries what the element actually believes.
70
+ const report = (state, error) => {
71
+ const duration = Number.isFinite(element.duration) ? element.duration : null;
72
+ const payload = {
73
+ state,
74
+ position: Number.isFinite(element.currentTime) ? element.currentTime : 0,
75
+ duration,
76
+ ...(error ? { error } : {}),
77
+ };
78
+ link.post(module, MEDIA_PLAYER_REPORT, { payload });
79
+ };
80
+ // The PENDING start-at seek, if any. 🔴 It has to be cancellable: `{ once: true }` removes a listener
81
+ // only when it FIRES, and a second PLAYER_LOAD calls `element.load()`, which aborts the first load so
82
+ // its `loadedmetadata` never comes. The stale listener then survives and runs on the NEXT track's
83
+ // metadata — so loading A at 10:00 and then B at 0:00 starts B ten minutes in, because B sets no
84
+ // listener of its own and A's is still attached.
85
+ let pendingSeek = null;
86
+ const cancelPendingSeek = () => {
87
+ if (pendingSeek)
88
+ element.removeEventListener('loadedmetadata', pendingSeek);
89
+ pendingSeek = null;
90
+ };
91
+ // ── host → element ────────────────────────────────────────────────────────────────────────────
92
+ const subscriptions = [
93
+ eventBus.subscribe(module, MediaPlayerCommands.load, (message) => {
94
+ const { uri, startAt } = message.payload ?? { uri: '', startAt: 0 };
95
+ element.src = uri;
96
+ // load() rather than trusting the src assignment: a second load on the same element keeps the
97
+ // previous buffer otherwise, and a seek then lands in the OLD media.
98
+ element.load();
99
+ cancelPendingSeek(); // this load supersedes any earlier one — see pendingSeek
100
+ if (startAt > 0) {
101
+ const seek = () => { pendingSeek = null; element.currentTime = startAt; };
102
+ // `loadedmetadata` is the earliest point currentTime is settable — before it, the assignment is
103
+ // silently dropped and the item starts at zero.
104
+ pendingSeek = seek;
105
+ element.addEventListener('loadedmetadata', seek, { once: true });
106
+ }
107
+ }),
108
+ eventBus.subscribe(module, MediaPlayerCommands.play, () => {
109
+ // A rejected play() is an autoplay refusal, and the host must hear it rather than assume success.
110
+ // ⚠ Read `.name` STRUCTURALLY rather than testing `instanceof Error`: the value browsers reject
111
+ // with is a DOMException, which is not an Error subclass everywhere (jsdom's is not), and a page
112
+ // can reject with anything at all. The name is the stable, app-safe part — `NotAllowedError` is
113
+ // what an autoplay block actually says, and it is the one an adopter will want to branch on.
114
+ void element.play().catch((cause) => {
115
+ const name = cause?.name;
116
+ report('Failed', typeof name === 'string' && name ? name : 'PlayRejected');
117
+ });
118
+ }),
119
+ eventBus.subscribe(module, MediaPlayerCommands.pause, () => element.pause()),
120
+ eventBus.subscribe(module, MediaPlayerCommands.seek, (message) => {
121
+ element.currentTime = message.payload?.position ?? 0;
122
+ }),
123
+ eventBus.subscribe(module, MediaPlayerCommands.rate, (message) => {
124
+ element.playbackRate = message.payload?.rate ?? 1;
125
+ }),
126
+ eventBus.subscribe(module, MediaPlayerCommands.unload, () => {
127
+ element.pause();
128
+ element.removeAttribute('src');
129
+ // ⚠ load() after clearing src is what actually FREES the buffer. Without it the element keeps the
130
+ // decoded data alive, which on a phone is the difference between releasing memory and not.
131
+ element.load();
132
+ report('Empty');
133
+ }),
134
+ ];
135
+ // ── element → host ────────────────────────────────────────────────────────────────────────────
136
+ // Transitions only. `timeupdate` is deliberately absent — see the remarks.
137
+ const listeners = [
138
+ ['loadedmetadata', () => report('Paused')],
139
+ ['canplay', () => report(element.paused ? 'Paused' : 'Playing')],
140
+ ['play', () => report('Playing')],
141
+ ['playing', () => report('Playing')],
142
+ ['pause', () => report(element.ended ? 'Ended' : 'Paused')],
143
+ ['waiting', () => report('Buffering')],
144
+ ['seeked', () => report(element.paused ? 'Paused' : 'Playing')],
145
+ ['ended', () => report('Ended')],
146
+ ['error', () => report('Failed', mediaErrorReason(element))],
147
+ ];
148
+ for (const [event, handler] of listeners)
149
+ element.addEventListener(event, handler);
150
+ // 🔴 REPORT WHEN THE PAGE IS ABOUT TO BE HIDDEN, or the host's position is whatever the last
151
+ // TRANSITION left — which for steady playback is the moment it started.
152
+ //
153
+ // Measured on an Android emulator 2026-08-15: with transition-only reporting the page sat at 19.79 s
154
+ // while the host believed 0.01 s, and `BackgroundPlaybackTransfer` handed the native player 0.01 s.
155
+ // The user backgrounds mid-film and resumes from the beginning. The platform's `pause` at background
156
+ // time does fire, but not in time to cross IPC before the process is frozen.
157
+ //
158
+ // ⚠ `visibilitychange` rather than `pagehide`: this fires while the document is still alive and the
159
+ // bridge can still post, and it is the signal both mobile shells raise on the way to the background.
160
+ // It costs ONE report per background — nothing like `timeupdate`'s ~4/second, which is why that one
161
+ // is still deliberately absent.
162
+ const onHidden = () => {
163
+ if (document.visibilityState !== 'hidden')
164
+ return;
165
+ report(element.paused ? (element.ended ? 'Ended' : 'Paused') : 'Playing');
166
+ };
167
+ document.addEventListener('visibilitychange', onHidden);
168
+ return () => {
169
+ for (const subscription of subscriptions)
170
+ subscription();
171
+ for (const [event, handler] of listeners)
172
+ element.removeEventListener(event, handler);
173
+ cancelPendingSeek();
174
+ document.removeEventListener('visibilitychange', onHidden);
175
+ };
176
+ }, [ref, module, bridge, eventBus]);
177
+ }
178
+ /**
179
+ * A short, stable reason from `MediaError`.
180
+ *
181
+ * ⚠ Deliberately NOT `error.message`: browsers put decoder internals and sometimes the full URL in it, and
182
+ * this string crosses to the host and can reach a log. The host applies the same rule to platform errors.
183
+ */
184
+ function mediaErrorReason(element) {
185
+ switch (element.error?.code) {
186
+ case 1: return 'Aborted';
187
+ case 2: return 'Network';
188
+ case 3: return 'Decode';
189
+ case 4: return 'SourceNotSupported';
190
+ default: return 'Unknown';
191
+ }
192
+ }
@@ -9,11 +9,18 @@ import { type ShenoraBridge } from './bridge.js';
9
9
  * interface NoteRequests { GET_ALL: void; ADD: { title: string } }
10
10
  * class NoteService extends BaseModuleService<NoteRequests> {
11
11
  * constructor() { super('NOTES'); }
12
- * getAll() { return this.send<Note[]>('GET_ALL'); }
13
- * add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
12
+ * getAll(): Promise<Note[]> { return this.send('GET_ALL'); }
13
+ * add(title: string): Promise<Note> { return this.send('ADD', { payload: { title } }); }
14
14
  * }
15
15
  * ```
16
16
  *
17
+ * 🔴 **DECLARE THE RETURN TYPE; NEVER WRITE `send<Note>(…)`.** TypeScript has no partial type-argument
18
+ * inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
19
+ * key — and `payload` collapses to the union of every route's payload. The check silently stops
20
+ * checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
21
+ * without the type argument is a TS2353. The response is inferred from the method's declared return
22
+ * type instead, which every method here has anyway. `moduleService.test.ts` pins both halves.
23
+ *
17
24
  * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
18
25
  * around the same call — the response generic already expresses them, so they're gone.
19
26
  *
@@ -9,11 +9,18 @@ import { getBridge } from './bridge.js';
9
9
  * interface NoteRequests { GET_ALL: void; ADD: { title: string } }
10
10
  * class NoteService extends BaseModuleService<NoteRequests> {
11
11
  * constructor() { super('NOTES'); }
12
- * getAll() { return this.send<Note[]>('GET_ALL'); }
13
- * add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
12
+ * getAll(): Promise<Note[]> { return this.send('GET_ALL'); }
13
+ * add(title: string): Promise<Note> { return this.send('ADD', { payload: { title } }); }
14
14
  * }
15
15
  * ```
16
16
  *
17
+ * 🔴 **DECLARE THE RETURN TYPE; NEVER WRITE `send<Note>(…)`.** TypeScript has no partial type-argument
18
+ * inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
19
+ * key — and `payload` collapses to the union of every route's payload. The check silently stops
20
+ * checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
21
+ * without the type argument is a TS2353. The response is inferred from the method's declared return
22
+ * type instead, which every method here has anyway. `moduleService.test.ts` pins both halves.
23
+ *
17
24
  * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
18
25
  * around the same call — the response generic already expresses them, so they're gone.
19
26
  *
@@ -0,0 +1,176 @@
1
+ import type { ShenoraBridge } from './bridge.js';
2
+ import type { ShenoraEventBus } from './eventBus.js';
3
+ import { type ShenoraStore } from './store.js';
4
+ import type { IpcError } from './types.js';
5
+ /**
6
+ * Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2) — crosses the wire as its camelCase name for
7
+ * free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
8
+ * needed on either side. Pinned against the host by
9
+ * `WireMirrorTests.Every_request_state_exists_on_both_sides` — a status added on one side and
10
+ * not the other fails that test by name, not by a green suite that never looked.
11
+ */
12
+ export declare const IpcRequestStates: {
13
+ readonly Running: "running";
14
+ readonly Completed: "completed";
15
+ readonly Failed: "failed";
16
+ readonly Cancelled: "cancelled";
17
+ };
18
+ /** One of {@link IpcRequestStates}. */
19
+ export type IpcRequestState = (typeof IpcRequestStates)[keyof typeof IpcRequestStates];
20
+ /**
21
+ * Mirrors `Shenora.Core.Ipc.IpcRequestEvents` — pinned against the host by
22
+ * `WireMirrorTests.Request_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
23
+ * these were bare string literals with nothing comparing them to the host's own constants, so a host
24
+ * rename left the suite green and the client permanently deaf to the renamed event.
25
+ */
26
+ export declare const IpcRequestEventTypes: {
27
+ readonly Updated: "REQUEST_UPDATED";
28
+ /**
29
+ * One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
30
+ * and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
31
+ * by deleting those ids. One authoritative event, so a client never has to guess what the host
32
+ * removed.
33
+ */
34
+ readonly Removed: "REQUEST_REMOVED";
35
+ };
36
+ /**
37
+ * Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on (its own
38
+ * `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
39
+ * `WireMirrorTests.Request_route_names_match_the_hosts_module`, same rationale as
40
+ * {@link IpcRequestEventTypes}.
41
+ */
42
+ export declare const IpcRequestRoutes: {
43
+ readonly List: "LIST";
44
+ readonly Cancel: "CANCEL";
45
+ readonly ClearFinished: "CLEAR_FINISHED";
46
+ };
47
+ /**
48
+ * Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
49
+ * `WireMirrorTests.The_default_requests_module_name_matches_the_host`.
50
+ */
51
+ export declare const IpcRequestsModuleName = "SHENORA.REQUESTS";
52
+ /**
53
+ * Mirrors `Shenora.Core.Ipc.IpcLabel` — human-facing text the HOST never renders itself: an
54
+ * untranslated fallback plus an app i18n key and interpolation parameters (headless, D13).
55
+ */
56
+ export interface IpcLabel {
57
+ text?: string;
58
+ key?: string;
59
+ parameters?: Record<string, string>;
60
+ }
61
+ /**
62
+ * Mirrors `Shenora.Core.Ipc.IpcProgress` — how far a tracked operation has gotten, in the APP's own
63
+ * unit, never a kit-assumed percent (generic-library audit, before publish: percent is not the
64
+ * mechanism, it is one way an app happens to measure). `total` is the denominator when one is known;
65
+ * `undefined` means there is NO known total — an absolute count with nothing to divide by (bytes
66
+ * streamed so far off a chunked response, say), never zero. `unit` is app-defined, like `kind`
67
+ * (`'bytes'`, `'files'`, `'percent'`) — the kit never interprets it and ships no percent helper: render
68
+ * a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
69
+ * is the consumer's own policy (see the README example).
70
+ */
71
+ export interface IpcProgress {
72
+ value: number;
73
+ total?: number;
74
+ unit?: string;
75
+ }
76
+ /**
77
+ * Mirrors `Shenora.Core.Ipc.IpcRequestStatus` — a full snapshot of one tracked operation. Every lifecycle
78
+ * transition (start, progress, terminal) publishes one of these under `REQUEST_UPDATED`, so the
79
+ * client folds by `id`: last write wins, with no cross-type ordering hazard.
80
+ */
81
+ export interface IpcRequestStatus {
82
+ id: string;
83
+ module: string;
84
+ type: string;
85
+ scope?: string;
86
+ state: IpcRequestState;
87
+ progress?: IpcProgress;
88
+ detail?: IpcLabel;
89
+ error?: IpcError;
90
+ startedAt: string;
91
+ finishedAt?: string;
92
+ }
93
+ /**
94
+ * State behind {@link useShenoraRequests}. TWO bands, because a request is in flight or done:
95
+ *
96
+ * | Band | Getter |
97
+ * |---|---|
98
+ * | In flight | {@link running} |
99
+ * | Finished | {@link finished} — prunable via `clearFinished` |
100
+ *
101
+ * Every getter is DERIVED from `byId` on each read — never a second copy a fold has to remember to
102
+ * keep in sync. `byId` is the only thing any reducer here writes.
103
+ *
104
+ * ⚠ Most requests never appear at all: one that finishes inside the host's grace period is never
105
+ * announced, so this store is a list of work that is actually TAKING A WHILE rather than a log of
106
+ * every call the page made.
107
+ */
108
+ export interface RequestsState {
109
+ byId: Record<string, IpcRequestStatus>;
110
+ /** Every currently-running operation, in `byId` order. */
111
+ readonly running: IpcRequestStatus[];
112
+ /** Every operation that reached a terminal status (completed/failed/cancelled). */
113
+ readonly finished: IpcRequestStatus[];
114
+ }
115
+ /** Fire-and-forget actions exposed on {@link useShenoraRequests}, routed to `IpcRequestsModule`. */
116
+ export interface RequestsActions {
117
+ /** `CANCEL { requestId }` — the app-level cancel route `ipc-contracts` prescribes. */
118
+ cancel: (requestId: string) => string;
119
+ /**
120
+ * `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
121
+ * configured scope (generic-library audit finding 1) so a scoped store's "clear completed" cannot
122
+ * wipe another scope's history host-side. No local mutation here: the host's
123
+ * `REQUEST_REMOVED` (finding 4) is the only thing that removes a row from this store now — see
124
+ * {@link IpcRequestEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
125
+ * entry, added because removals had no wire event at all; that guess is retired now that one exists.
126
+ */
127
+ clearFinished: () => string;
128
+ }
129
+ /** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createRequestsStore}. */
130
+ export interface RequestsStoreOptions {
131
+ /**
132
+ * The request/event module this store talks to. Must match the host's
133
+ * `IpcRequestTrackerOptions.ModuleName` — default `'SHENORA.REQUESTS'` on both sides — when an app
134
+ * renamed it to avoid a collision with one of its own module names (the duplicate-module guard
135
+ * `IpcRequestsModule`'s own docs describe). A store bound to the default name cannot reach a
136
+ * renamed host at all, which is exactly the gap this field closes.
137
+ */
138
+ module?: string;
139
+ /**
140
+ * Optional app-defined scope, applied to THREE places so the store stays internally consistent:
141
+ * the bus subscription (only deltas whose event scope matches are folded), the actions' request
142
+ * envelope, and the initial `LIST` snapshot's payload (`IpcRequestsModule` reads its scope filter
143
+ * from the payload, not the envelope — see `IpcRequestsModule.RouteMessageAsync`). Threading it
144
+ * into only the first two would load every scope on first subscribe and never remove the
145
+ * out-of-scope rows, since no delta for them ever arrives: a silent, permanent leak.
146
+ */
147
+ scope?: string;
148
+ /** Test/multi-transport seams. Default: the shared bridge and event bus. */
149
+ bridge?: ShenoraBridge;
150
+ bus?: ShenoraEventBus;
151
+ }
152
+ /**
153
+ * Build a store instance over the requests module — the factory {@link useShenoraRequests}
154
+ * itself is built from. Exposed (rather than only the ready-made hook) for the same reason
155
+ * `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
156
+ * (`requests.test.ts`), an app that renamed the host's `IpcRequestTrackerOptions.ModuleName`
157
+ * needs a store bound to that name instead of the unreachable default, and an app running a
158
+ * secondary window or auxiliary session needs its own scope-filtered instance instead of being
159
+ * stuck with the shared, unscoped default.
160
+ */
161
+ export declare function createRequestsStore(options?: RequestsStoreOptions): ShenoraStore<RequestsState, RequestsActions>;
162
+ /**
163
+ * The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
164
+ * `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
165
+ * id — one subscription however many components read it, and a late mounter renders CURRENT state
166
+ * because the host is authoritative (the store primitive's own late-mounter case is now
167
+ * host-backed end to end). `running`/`finished` are selectors an activity panel or status bar reads
168
+ * directly: `useShenoraRequests((s) => s.running)`. There is no `waiting` band and no `waitReason` —
169
+ * a request is IN FLIGHT or DONE, the same two states `XMLHttpRequest` has, since D66. Bound to the
170
+ * default module/no scope — use {@link createRequestsStore} directly for a renamed module or a
171
+ * scope-filtered instance.
172
+ *
173
+ * Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
174
+ * IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
175
+ */
176
+ export declare const useShenoraRequests: ShenoraStore<RequestsState, RequestsActions>;
@@ -0,0 +1,146 @@
1
+ import { createShenoraStore } from './store.js';
2
+ /**
3
+ * Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2) — crosses the wire as its camelCase name for
4
+ * free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
5
+ * needed on either side. Pinned against the host by
6
+ * `WireMirrorTests.Every_request_state_exists_on_both_sides` — a status added on one side and
7
+ * not the other fails that test by name, not by a green suite that never looked.
8
+ */
9
+ export const IpcRequestStates = {
10
+ Running: 'running',
11
+ Completed: 'completed',
12
+ Failed: 'failed',
13
+ Cancelled: 'cancelled',
14
+ // NO 'waiting'. A request is IN FLIGHT or DONE — the XHR model this mirrors has no parked state,
15
+ // and neither does the host since D66. Work that parks awaiting a human is host-initiated work
16
+ // (a queued mission), which reports on its own event stream rather than as a request.
17
+ };
18
+ /**
19
+ * Mirrors `Shenora.Core.Ipc.IpcRequestEvents` — pinned against the host by
20
+ * `WireMirrorTests.Request_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
21
+ * these were bare string literals with nothing comparing them to the host's own constants, so a host
22
+ * rename left the suite green and the client permanently deaf to the renamed event.
23
+ */
24
+ export const IpcRequestEventTypes = {
25
+ Updated: 'REQUEST_UPDATED',
26
+ /**
27
+ * One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
28
+ * and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
29
+ * by deleting those ids. One authoritative event, so a client never has to guess what the host
30
+ * removed.
31
+ */
32
+ Removed: 'REQUEST_REMOVED',
33
+ };
34
+ /**
35
+ * Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on (its own
36
+ * `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
37
+ * `WireMirrorTests.Request_route_names_match_the_hosts_module`, same rationale as
38
+ * {@link IpcRequestEventTypes}.
39
+ */
40
+ export const IpcRequestRoutes = {
41
+ List: 'LIST',
42
+ Cancel: 'CANCEL',
43
+ ClearFinished: 'CLEAR_FINISHED',
44
+ // THREE routes — the same three `XMLHttpRequest` offers. RESUME/WAIT/DISMISS went with the waiting
45
+ // band (D66), and the wire-mirror test pins this object's SIZE so a retired name cannot creep back.
46
+ };
47
+ /**
48
+ * Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
49
+ * `WireMirrorTests.The_default_requests_module_name_matches_the_host`.
50
+ */
51
+ export const IpcRequestsModuleName = 'SHENORA.REQUESTS';
52
+ /** The terminal states — everything that is not in flight. */
53
+ const TERMINAL_STATES = new Set([
54
+ IpcRequestStates.Completed,
55
+ IpcRequestStates.Failed,
56
+ IpcRequestStates.Cancelled,
57
+ ]);
58
+ function index(list) {
59
+ const byId = {};
60
+ for (const operation of list)
61
+ byId[operation.id] = operation;
62
+ return byId;
63
+ }
64
+ /**
65
+ * The one place `running`/`finished` are computed — wrap `byId` here, nowhere else.
66
+ */
67
+ function makeState(byId) {
68
+ return {
69
+ byId,
70
+ get running() {
71
+ return Object.values(byId).filter((request) => request.state === IpcRequestStates.Running);
72
+ },
73
+ get finished() {
74
+ return Object.values(byId).filter((request) => TERMINAL_STATES.has(request.state));
75
+ },
76
+ };
77
+ }
78
+ /**
79
+ * Build a store instance over the requests module — the factory {@link useShenoraRequests}
80
+ * itself is built from. Exposed (rather than only the ready-made hook) for the same reason
81
+ * `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
82
+ * (`requests.test.ts`), an app that renamed the host's `IpcRequestTrackerOptions.ModuleName`
83
+ * needs a store bound to that name instead of the unreachable default, and an app running a
84
+ * secondary window or auxiliary session needs its own scope-filtered instance instead of being
85
+ * stuck with the shared, unscoped default.
86
+ */
87
+ export function createRequestsStore(options = {}) {
88
+ const module = options.module ?? IpcRequestsModuleName;
89
+ return createShenoraStore(module, {
90
+ initial: makeState({}),
91
+ // LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
92
+ // that mounts while work is already running gets it from here before folding any deltas.
93
+ // The payload carries `scope` so the initial load is filtered the SAME way the deltas are
94
+ // (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
95
+ // scoped store loads every scope once and then never sheds the out-of-scope rows.
96
+ snapshot: {
97
+ type: IpcRequestRoutes.List,
98
+ payload: options.scope !== undefined ? { scope: options.scope } : undefined,
99
+ apply: (_state, data) => makeState(index(data)),
100
+ },
101
+ on: {
102
+ // ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
103
+ // no ordering logic and no cross-type races.
104
+ [IpcRequestEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
105
+ // The ONE removal delta (Finding 4, generic-library audit), replacing the two hand-written
106
+ // optimistic prunes `clearFinished`/`resume` used to carry (see their own docs below) — deletes
107
+ // exactly the ids the host named, regardless of status; an id this store never had is a no-op.
108
+ [IpcRequestEventTypes.Removed]: (state, payload) => {
109
+ const byId = { ...state.byId };
110
+ for (const id of payload.requestIds)
111
+ delete byId[id];
112
+ return makeState(byId);
113
+ },
114
+ },
115
+ actions: ({ post }) => ({
116
+ cancel: (requestId) => post(IpcRequestRoutes.Cancel, { payload: { requestId } }),
117
+ clearFinished: () =>
118
+ // Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
119
+ // key the LIST snapshot payload already carries above. No local mutation here any more
120
+ // (Finding 4): the host's REQUEST_REMOVED is the ONLY thing that removes a row now, which
121
+ // is also what makes the scope threading safe to add — nothing here can diverge from what
122
+ // the host actually cleared.
123
+ post(IpcRequestRoutes.ClearFinished, {
124
+ payload: options.scope !== undefined ? { scope: options.scope } : undefined,
125
+ }),
126
+ }),
127
+ scope: options.scope,
128
+ bridge: options.bridge,
129
+ bus: options.bus,
130
+ });
131
+ }
132
+ /**
133
+ * The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
134
+ * `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
135
+ * id — one subscription however many components read it, and a late mounter renders CURRENT state
136
+ * because the host is authoritative (the store primitive's own late-mounter case is now
137
+ * host-backed end to end). `running`/`finished` are selectors an activity panel or status bar reads
138
+ * directly: `useShenoraRequests((s) => s.running)`. There is no `waiting` band and no `waitReason` —
139
+ * a request is IN FLIGHT or DONE, the same two states `XMLHttpRequest` has, since D66. Bound to the
140
+ * default module/no scope — use {@link createRequestsStore} directly for a renamed module or a
141
+ * scope-filtered instance.
142
+ *
143
+ * Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
144
+ * IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
145
+ */
146
+ export const useShenoraRequests = createRequestsStore();
@@ -0,0 +1,87 @@
1
+ import { type MediaSourceGlobals } from './segmentStream.js';
2
+ /**
3
+ * The imperative half of the segment route (D71 piece 4b): open a `SourceBuffer`, feed it, and stop when
4
+ * the platform says stop.
5
+ *
6
+ * 🔴 **Everything here was written against measurements rather than against the specification**, because
7
+ * three implementations disagree in ways the spec permits and none of it is guessable:
8
+ *
9
+ * - **Attachment is not portable.** iOS takes `srcObject` — a `ManagedMediaSource` is a valid
10
+ * `MediaSourceHandle` — and Chromium refuses it outright ("not of type '(MediaSourceHandle or
11
+ * MediaStream)'"), wanting an object URL. Feature-detected, not branched on the shell: which one works
12
+ * is a property of the MediaSource, not of the OS.
13
+ * - **The codecs are read from the init segment, never assumed.** The track set is a fact about the
14
+ * DEVICE, not the source: the same file yields a two-track init on iOS and a video-only one on Android,
15
+ * which cannot decode its AC-3 soundtrack. A mismatch kills the FIRST append and plays nothing.
16
+ * - **The streaming gate is real on iOS and absent elsewhere.** `endstreaming` fires once enough is
17
+ * buffered (measured: at 60 s, not at 6 s), and fetching past it is the misuse `ManagedMediaSource`
18
+ * exists to detect. A plain `MediaSource` has neither event nor a `streaming` property, and its absence
19
+ * means "always streaming" — never "never asked".
20
+ *
21
+ * ⚠ **The dependencies are INJECTABLE so this is testable without a browser.** jsdom has no MediaSource,
22
+ * and "cannot be verified anywhere this repo runs" was true of the whole file until the seams below
23
+ * existed. A fake source and a fake fetch drive every branch here.
24
+ */
25
+ export interface SegmentBinderOptions {
26
+ /** The playlist URL. Segment URIs are resolved relative to it. */
27
+ manifest: string;
28
+ /** The element to play into. Only the members this binder touches are required. */
29
+ element: HTMLMediaElement;
30
+ /** Where to look for a MediaSource. Defaults to `globalThis`. */
31
+ globals?: MediaSourceGlobals;
32
+ /** Defaults to `globalThis.fetch`. */
33
+ fetch?: (url: string) => Promise<{
34
+ ok: boolean;
35
+ status: number;
36
+ arrayBuffer(): Promise<ArrayBuffer>;
37
+ text(): Promise<string>;
38
+ }>;
39
+ /** Defaults to `URL.createObjectURL`. Only used when `srcObject` is refused. */
40
+ createObjectURL?: (source: object) => string;
41
+ /**
42
+ * Defaults to `URL.revokeObjectURL`. The pair to {@link createObjectURL}, and injectable for the same
43
+ * reason: without it the failure paths that must revoke — a source that closes before opening, a
44
+ * codec the device refuses — cannot be asserted, only hoped for.
45
+ */
46
+ revokeObjectURL?: (url: string) => void;
47
+ /** Stop fetching once this many seconds are buffered ahead. Defaults to 30. */
48
+ targetAheadSeconds?: number;
49
+ /** Diagnostics. Every decision that could stall playback reports through here. */
50
+ onDiagnostic?: (line: string) => void;
51
+ }
52
+ /** A live binding. Dispose it when the element goes away — it detaches every listener it added. */
53
+ export interface SegmentBinding {
54
+ /** Indices appended so far. */
55
+ readonly appended: ReadonlySet<number>;
56
+ /** False while a managed source has said stop. Always true where the platform has no such signal. */
57
+ readonly streaming: boolean;
58
+ /** Which attachment this implementation accepted — the difference between the two shells. */
59
+ readonly attachedBy: 'srcObject' | 'objectURL';
60
+ /** What the SourceBuffer was opened with, read from the init segment. */
61
+ readonly codecs: string;
62
+ /** Detach listeners and release the object URL, if one was minted. */
63
+ dispose(): void;
64
+ }
65
+ /**
66
+ * Thrown for every reason a stream cannot start, so a caller has one thing to catch.
67
+ *
68
+ * ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch failing at the network layer,
69
+ * and a `RangeError` from a truncated init segment, both propagate as themselves. Catch broadly if you
70
+ * need to be exhaustive.
71
+ */
72
+ export declare class SegmentBinderError extends Error {
73
+ constructor(message: string);
74
+ }
75
+ /**
76
+ * Open a MediaSource for `options.manifest` and keep it fed.
77
+ *
78
+ * Resolves once the init segment has been appended — the point after which the element can play — and
79
+ * goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
80
+ *
81
+ * ⚠ **There is deliberately NO `useSegmentStream` hook.** This needs no React — it takes an element and
82
+ * returns a handle, exactly as `mediaUrl` takes a path and returns a string — so a hook would add a
83
+ * lifecycle without adding a capability. The case that would earn one is an app wanting load/error
84
+ * state as component state, and the shape of that hook depends on what such an app actually asks for;
85
+ * inventing it first is how a seam nothing consults gets built (D63). Call this from an effect.
86
+ */
87
+ export declare function bindSegmentStream(options: SegmentBinderOptions): Promise<SegmentBinding>;