@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.
- package/README.md +152 -165
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +19 -10
- package/dist/clipboard.d.ts +94 -0
- package/dist/clipboard.js +126 -0
- package/dist/devInterceptor.d.ts +9 -2
- package/dist/devInterceptor.js +15 -4
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -3
- package/dist/eventBus.d.ts +15 -5
- package/dist/eventBus.js +29 -10
- package/dist/fileDialogs.d.ts +1 -1
- package/dist/fileDialogs.js +4 -4
- package/dist/hooks.d.ts +9 -1
- package/dist/hooks.js +12 -3
- package/dist/index.d.ts +8 -2
- package/dist/index.js +17 -5
- package/dist/mediaPlayer.d.ts +86 -0
- package/dist/mediaPlayer.js +192 -0
- package/dist/moduleService.d.ts +9 -2
- package/dist/moduleService.js +9 -2
- package/dist/requests.d.ts +176 -0
- package/dist/requests.js +146 -0
- package/dist/segmentBinder.d.ts +87 -0
- package/dist/segmentBinder.js +256 -0
- package/dist/segmentStream.d.ts +136 -0
- package/dist/segmentStream.js +248 -0
- package/dist/store.d.ts +10 -7
- package/dist/store.js +74 -13
- package/dist/types.d.ts +26 -1
- package/dist/types.js +26 -1
- package/dist/useDropZone.d.ts +16 -3
- package/dist/useDropZone.js +28 -7
- package/dist/windowCommands.d.ts +1 -1
- package/dist/windowCommands.js +2 -2
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
|
@@ -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
|
+
}
|
package/dist/moduleService.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
* add(title: string) { return this.send
|
|
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
|
*
|
package/dist/moduleService.js
CHANGED
|
@@ -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
|
|
13
|
-
* add(title: string) { return this.send
|
|
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>;
|
package/dist/requests.js
ADDED
|
@@ -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>;
|