@shenora/react 0.9.1 → 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/dist/index.d.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, ShellCapabilities, type ShellInfo, } from './types.js';
2
- export { OperationError } from './errors.js';
2
+ export { ShenoraError } from './errors.js';
3
3
  export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
4
- export { ShenoraEventBus, eventBus } from './eventBus.js';
4
+ export { ShenoraEventBus, eventBus, type SubscribeOptions, } from './eventBus.js';
5
5
  export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
6
6
  export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
7
7
  export { BaseModuleService } from './moduleService.js';
8
- export { OperationStatuses, OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, type OperationStatus, type OperationLabel, type OperationProgress, type OperationInfo, type OperationsState, type OperationsActions, type OperationsStoreOptions, } from './operations.js';
8
+ export { IpcRequestStates, IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, type IpcRequestState, type IpcLabel, type IpcProgress, type IpcRequestStatus, type RequestsState, type RequestsActions, type RequestsStoreOptions, } from './requests.js';
9
9
  export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
10
10
  export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
11
- export { useShenora, useShenoraEvent, useShenoraQuery, type ShenoraQueryResult } from './hooks.js';
11
+ export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
12
+ export { FileDialogs, useFileDialogs, type FileDialogFilter, type FileDialogOptions, type OpenFileOptions, type OpenFolderOptions, type SaveFileOptions, type FileDialogResult, type FileDialogsHandle, } from './fileDialogs.js';
13
+ export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, type ClipboardContent, type ClipboardHandle, } from './clipboard.js';
12
14
  export { installDevInterceptor, type DevInterceptorOptions, type DevIpcEntry, type DevEventEntry, } from './devInterceptor.js';
13
15
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
16
+ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, } from './segmentStream.js';
17
+ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
18
+ export type { SegmentBinderOptions, SegmentBinding } from './segmentBinder.js';
19
+ export type { SegmentEntry, SegmentManifest, MediaSourceKind, MediaSourceGlobals, FetchState, FetchPolicy, } from './segmentStream.js';
20
+ export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MediaPlayerCommands, type MediaPlayerReport, type MediaPlayerReportState, type UseMediaPlayerOptions, } from './mediaPlayer.js';
package/dist/index.js CHANGED
@@ -3,22 +3,36 @@
3
3
  // React hooks, and the dev interceptor for CDP-driven testing. Headless by design (D13): no UI
4
4
  // components, no design-system dependency — apps bring their own.
5
5
  export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
6
- export { OperationError } from './errors.js';
6
+ export { ShenoraError } from './errors.js';
7
7
  export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
8
- export { ShenoraEventBus, eventBus } from './eventBus.js';
8
+ export { ShenoraEventBus, eventBus, } from './eventBus.js';
9
9
  export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
10
10
  export { createShenoraStore, } from './store.js';
11
11
  export { BaseModuleService } from './moduleService.js';
12
- export { OperationStatuses,
13
- // The event vocabulary + the default module name. `createOperationsStore` deliberately does NOT
12
+ export { IpcRequestStates,
13
+ // The event vocabulary + the default module name. `createRequestsStore` deliberately does NOT
14
14
  // subscribe to RESUME_REQUESTED / WAIT_REQUESTED — those target the OWNING module's own service,
15
15
  // not the generic store — so the app writing that handler needs both symbols, and until now had
16
16
  // neither: it had to hard-code the literals the wire-mirror tests exist to keep it from doing.
17
- OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, } from './operations.js';
17
+ IpcRequestEventTypes,
18
+ // The ROUTE half of that same wire, and it was the one left behind: an app cancelling a request
19
+ // without `useShenoraRequests` had the module name and the event names but had to hard-code
20
+ // 'CANCEL'. Pinned by `WireMirrorTests.Request_route_names_match_the_hosts_module`.
21
+ IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, } from './requests.js';
18
22
  export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
19
23
  export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
20
- export { useShenora, useShenoraEvent, useShenoraQuery } from './hooks.js';
24
+ export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
25
+ // Native dialogs, capability-gated. The client half of the host's SHENORA.DIALOGS module.
26
+ export { FileDialogs, useFileDialogs, } from './fileDialogs.js';
27
+ // The native clipboard, for the two things navigator.clipboard cannot do: FILES, and access with no
28
+ // user gesture. The client half of the host's SHENORA.CLIPBOARD module.
29
+ export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, } from './clipboard.js';
21
30
  export { installDevInterceptor, } from './devInterceptor.js';
22
31
  // Addressing local content the page cannot reach itself. A pure function, not a hook — building the URL
23
32
  // needs no React, and a `useMediaSource` can follow if an adopter wants load/error state.
24
33
  export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
34
+ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, } from './segmentStream.js';
35
+ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
36
+ // The HOST-owned player (D58): .NET holds the lifecycle, the page's element is the display and the sound.
37
+ // One hook, and the page stops deciding anything about formats.
38
+ export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MediaPlayerCommands, } from './mediaPlayer.js';
@@ -0,0 +1,86 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ShenoraBridge } from './bridge.js';
3
+ import { type ShenoraEventBus } 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 declare 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 declare const MediaPlayerCommands: {
18
+ readonly load: "PLAYER_LOAD";
19
+ readonly play: "PLAYER_PLAY";
20
+ readonly pause: "PLAYER_PAUSE";
21
+ readonly seek: "PLAYER_SEEK";
22
+ readonly rate: "PLAYER_RATE";
23
+ readonly unload: "PLAYER_UNLOAD";
24
+ };
25
+ /** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
26
+ export declare const MEDIA_PLAYER_REPORT = "PLAYER_REPORT";
27
+ /**
28
+ * What the element is doing, in the host's vocabulary (`MediaPlayerState`).
29
+ *
30
+ * ⚠ `opening` and `buffering` are distinct, matching the host: opening is "no position yet", buffering is
31
+ * "had one and it stopped moving". Collapsing them makes a UI extrapolate a position that is not advancing.
32
+ */
33
+ export type MediaPlayerReportState = 'Empty' | 'Opening' | 'Paused' | 'Playing' | 'Buffering' | 'Ended' | 'Failed';
34
+ /** One state report, sent on TRANSITIONS only. */
35
+ export interface MediaPlayerReport {
36
+ state: MediaPlayerReportState;
37
+ /** Seconds. */
38
+ position: number;
39
+ /** Seconds, or null for a live stream / not yet known. */
40
+ duration: number | null;
41
+ /** A short reason when `state` is `Failed`; never the platform's raw text. */
42
+ error?: string;
43
+ }
44
+ /** Inputs for {@link useMediaPlayer}. */
45
+ export interface UseMediaPlayerOptions {
46
+ /** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
47
+ module?: string;
48
+ /** Test seams. */
49
+ bridge?: ShenoraBridge;
50
+ eventBus?: ShenoraEventBus;
51
+ }
52
+ /**
53
+ * Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
54
+ * and .NET owns the lifecycle (D58).
55
+ *
56
+ * ```tsx
57
+ * const ref = useRef<HTMLVideoElement>(null);
58
+ * useMediaPlayer(ref);
59
+ * return <video ref={ref} playsInline />;
60
+ * ```
61
+ *
62
+ * **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
63
+ * calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
64
+ * it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
65
+ * at all, which needs a probe and a device capability query).
66
+ *
67
+ * ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
68
+ * (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
69
+ * registered by the media feature itself. If you wrote that route by hand against a build from before
70
+ * 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
71
+ *
72
+ * ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
73
+ * object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
74
+ * the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
75
+ * the component so the hook remounts with it.
76
+ *
77
+ * ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
78
+ * would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
79
+ * you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
80
+ * actually redraw.
81
+ *
82
+ * ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
83
+ * the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
84
+ * hears about it rather than silently believing playback started.
85
+ */
86
+ export declare function useMediaPlayer(ref: RefObject<HTMLMediaElement | null>, options?: UseMediaPlayerOptions): void;
@@ -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>;