@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/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 +153 -0
- package/dist/fileDialogs.js +90 -0
- package/dist/hooks.d.ts +32 -2
- package/dist/hooks.js +36 -3
- package/dist/index.d.ts +11 -4
- package/dist/index.js +20 -6
- 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 +39 -1
- package/dist/types.js +39 -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
package/dist/useDropZone.js
CHANGED
|
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { getBridge } from './bridge.js';
|
|
3
3
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
4
|
import { debounce, randomId } from './internal.js';
|
|
5
|
-
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`
|
|
6
|
-
export const DROP_ZONE_MODULE = '
|
|
5
|
+
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
|
|
6
|
+
export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
|
|
7
7
|
const newZoneId = () => randomId('drop-zone-');
|
|
8
8
|
/**
|
|
9
9
|
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
@@ -30,13 +30,34 @@ const newZoneId = () => randomId('drop-zone-');
|
|
|
30
30
|
*/
|
|
31
31
|
export function useDropZone(options) {
|
|
32
32
|
const { targetRef, enabled = true } = options;
|
|
33
|
-
|
|
33
|
+
// ⚠ LAZY, because `useRef(newZoneId())` evaluates its argument on EVERY render and keeps only the
|
|
34
|
+
// first — so the generator ran a `crypto.randomUUID()` per render of every drop zone, for a value
|
|
35
|
+
// used once. The empty string is a safe sentinel: a generated id is never empty, and a caller who
|
|
36
|
+
// passes `zoneId: ''` short-circuits the `??` and so still never reaches the generator.
|
|
37
|
+
const zoneIdRef = useRef('');
|
|
38
|
+
if (zoneIdRef.current === '')
|
|
39
|
+
zoneIdRef.current = options.zoneId ?? newZoneId();
|
|
40
|
+
// ⚠ Read ONCE, like the id, and unlike `onDrop`/`bridge` below which track the latest value. That is
|
|
41
|
+
// deliberate rather than an oversight: the hover effect captures this class for its cleanup, while
|
|
42
|
+
// the FILE_DROP effect reads it live, so a value that could change mid-hover would let one path add
|
|
43
|
+
// class A and another remove class B — leaving A stuck on the element with no drag in progress. The
|
|
44
|
+
// default is a constant, so unlike the id there is nothing here worth making lazy.
|
|
34
45
|
const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
|
|
35
46
|
const onDropRef = useRef(options.onDrop);
|
|
36
47
|
onDropRef.current = options.onDrop;
|
|
37
48
|
const bridgeRef = useRef(options.bridge);
|
|
38
49
|
bridgeRef.current = options.bridge;
|
|
39
50
|
const bus = options.bus ?? defaultEventBus;
|
|
51
|
+
// Tracks the latest handler (like `onDrop`), so a cleanup that runs long after mount still reports
|
|
52
|
+
// through the sink the app has NOW. Only when the app supplied none does this log — a caller that
|
|
53
|
+
// took `onError` owns its reporting and must not be double-logged, the rule the package's other
|
|
54
|
+
// three sinks follow.
|
|
55
|
+
const onErrorRef = useRef(options.onError);
|
|
56
|
+
onErrorRef.current = options.onError;
|
|
57
|
+
const reportRef = useRef(() => { });
|
|
58
|
+
reportRef.current = (error, route) => onErrorRef.current
|
|
59
|
+
? onErrorRef.current(error, route)
|
|
60
|
+
: console.error(`[shenora] drop-zone ${route} failed:`, error);
|
|
40
61
|
// Make the ref's CONTENT reactive (P5.5 H2). `targetRef` is a stable object, so effects keyed on it
|
|
41
62
|
// run exactly once — and if `targetRef.current` was null on that run (a conditionally-rendered
|
|
42
63
|
// target, or any order where the ref is attached after the first commit) the effect bailed out and
|
|
@@ -91,7 +112,7 @@ export function useDropZone(options) {
|
|
|
91
112
|
.then(() => {
|
|
92
113
|
if (epochRef.current === epoch)
|
|
93
114
|
isRegisteredRef.current = true;
|
|
94
|
-
}, (error) =>
|
|
115
|
+
}, (error) => reportRef.current(error, 'REGISTER'))
|
|
95
116
|
.finally(() => {
|
|
96
117
|
if (epochRef.current === epoch)
|
|
97
118
|
registeringRef.current = false;
|
|
@@ -101,7 +122,7 @@ export function useDropZone(options) {
|
|
|
101
122
|
lastBoundsRef.current = bounds;
|
|
102
123
|
bridge
|
|
103
124
|
.invoke(DROP_ZONE_MODULE, 'UPDATE', { payload: { zoneId: zoneIdRef.current, ...bounds } })
|
|
104
|
-
.catch((error) =>
|
|
125
|
+
.catch((error) => reportRef.current(error, 'UPDATE'));
|
|
105
126
|
}
|
|
106
127
|
};
|
|
107
128
|
// Track the element and keep the native overlay in sync.
|
|
@@ -115,7 +136,7 @@ export function useDropZone(options) {
|
|
|
115
136
|
const sendShow = debounce(() => {
|
|
116
137
|
(bridgeRef.current ?? getBridge())
|
|
117
138
|
.invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
|
|
118
|
-
.catch((error) =>
|
|
139
|
+
.catch((error) => reportRef.current(error, 'SHOW'));
|
|
119
140
|
}, 100);
|
|
120
141
|
// The element's mouseleave (and the window losing focus) re-arm the overlay — native
|
|
121
142
|
// MouseLeave alone is unreliable through the WebView.
|
|
@@ -152,7 +173,7 @@ export function useDropZone(options) {
|
|
|
152
173
|
if (attemptedRef.current) {
|
|
153
174
|
(bridgeRef.current ?? getBridge())
|
|
154
175
|
.invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
|
|
155
|
-
.catch((error) =>
|
|
176
|
+
.catch((error) => reportRef.current(error, 'UNREGISTER'));
|
|
156
177
|
epochRef.current++; // invalidate any in-flight REGISTER's ack (see epochRef)
|
|
157
178
|
isRegisteredRef.current = false;
|
|
158
179
|
registeringRef.current = false; // a remount must re-send immediately
|
package/dist/windowCommands.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ interface WindowRequests {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Typed client for the host's `WINDOW` module (`
|
|
36
|
+
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
37
37
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
38
38
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
39
39
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
package/dist/windowCommands.js
CHANGED
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { debounce } from './internal.js';
|
|
3
3
|
import { BaseModuleService } from './moduleService.js';
|
|
4
4
|
/**
|
|
5
|
-
* Typed client for the host's `WINDOW` module (`
|
|
5
|
+
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
6
6
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
7
7
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
8
8
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
|
@@ -11,7 +11,7 @@ import { BaseModuleService } from './moduleService.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export class WindowCommands extends BaseModuleService {
|
|
13
13
|
constructor(bridge) {
|
|
14
|
-
super('WINDOW', bridge);
|
|
14
|
+
super('SHENORA.WINDOW', bridge);
|
|
15
15
|
}
|
|
16
16
|
minimize() {
|
|
17
17
|
return this.send('MINIMIZE');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shenora/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React client for Shenora
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the desktop postMessage bridge or the MAUI HybridWebView transport, typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jiarong Gu",
|
|
7
7
|
"repository": {
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"shenora",
|
|
15
15
|
"webview2",
|
|
16
16
|
"desktop",
|
|
17
|
+
"android",
|
|
18
|
+
"ios",
|
|
19
|
+
"maui",
|
|
20
|
+
"hybrid",
|
|
17
21
|
"ipc",
|
|
18
22
|
"react",
|
|
19
23
|
"winforms",
|
|
@@ -44,7 +48,9 @@
|
|
|
44
48
|
"//typecheck": "The ONLY thing that type-checks the tests — `build` excludes them and vitest transpiles without checking, so `@ts-expect-error` assertions (which pin the typed-service generic) are inert without this. Run by dev.mjs verify.",
|
|
45
49
|
"typecheck": "tsc -p tsconfig.json",
|
|
46
50
|
"test": "vitest run",
|
|
47
|
-
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
51
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
52
|
+
"//typecheck:floor": "The peerDependency FLOOR is a tested claim, not an assumption: type-checks the SHIPPED sources against React 18 types while everything else builds on 19, so an API that does not exist in 18 (useActionState, use, …) fails HERE instead of in a React 18 consumer's build. Run by dev.mjs verify.",
|
|
53
|
+
"typecheck:floor": "tsc -p tsconfig.react18.json"
|
|
48
54
|
},
|
|
49
55
|
"peerDependencies": {
|
|
50
56
|
"react": ">=18"
|
|
@@ -52,6 +58,7 @@
|
|
|
52
58
|
"devDependencies": {
|
|
53
59
|
"@testing-library/react": "^16.3.2",
|
|
54
60
|
"@types/react": "^19.2.17",
|
|
61
|
+
"@types/react18": "npm:@types/react@^18",
|
|
55
62
|
"jsdom": "^29.1.1",
|
|
56
63
|
"react": "^19.2.8",
|
|
57
64
|
"react-dom": "^19.2.8",
|
package/dist/operations.d.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
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.Ipc.OperationStatus` (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_operation_status_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 OperationStatuses: {
|
|
13
|
-
readonly Running: "running";
|
|
14
|
-
readonly Completed: "completed";
|
|
15
|
-
readonly Failed: "failed";
|
|
16
|
-
readonly Cancelled: "cancelled";
|
|
17
|
-
/**
|
|
18
|
-
* Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
|
|
19
|
-
* (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
|
|
20
|
-
* one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
|
|
21
|
-
* a waiting entry is a pending offer, not something finished.
|
|
22
|
-
*
|
|
23
|
-
* Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
|
|
24
|
-
* body is parked, not gone, so there is always something to resume. Exits via the host's
|
|
25
|
-
* `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
|
|
26
|
-
* complete/fail.
|
|
27
|
-
*
|
|
28
|
-
* Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
|
|
29
|
-
* rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
|
|
30
|
-
* host transition already treated as one band, and the host once also accepted crash-checkpoint
|
|
31
|
-
* entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
|
|
32
|
-
* to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
|
|
33
|
-
* the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
|
|
34
|
-
*/
|
|
35
|
-
readonly Waiting: "waiting";
|
|
36
|
-
};
|
|
37
|
-
/** One of {@link OperationStatuses}. */
|
|
38
|
-
export type OperationStatus = (typeof OperationStatuses)[keyof typeof OperationStatuses];
|
|
39
|
-
/**
|
|
40
|
-
* Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
|
|
41
|
-
* `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
|
|
42
|
-
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
43
|
-
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
44
|
-
*/
|
|
45
|
-
export declare const OperationEventTypes: {
|
|
46
|
-
readonly Updated: "OPERATION_UPDATED";
|
|
47
|
-
readonly ResumeRequested: "OPERATION_RESUME_REQUESTED";
|
|
48
|
-
/**
|
|
49
|
-
* A client asked to wait a running operation (generic-library audit finding 3, renamed from
|
|
50
|
-
* `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
|
|
51
|
-
* actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
|
|
52
|
-
* {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
|
|
53
|
-
* store.
|
|
54
|
-
*/
|
|
55
|
-
readonly WaitRequested: "OPERATION_WAIT_REQUESTED";
|
|
56
|
-
/**
|
|
57
|
-
* One or more operation ids left the host registry with no corresponding `Updated` snapshot —
|
|
58
|
-
* `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
|
|
59
|
-
* generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
|
|
60
|
-
* folds it by deleting those ids, which is what let the two hand-written optimistic prunes
|
|
61
|
-
* (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
|
|
62
|
-
* cannot diverge from what the host actually did, replacing two guesses that could.
|
|
63
|
-
*/
|
|
64
|
-
readonly Removed: "OPERATION_REMOVED";
|
|
65
|
-
};
|
|
66
|
-
/**
|
|
67
|
-
* Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
|
|
68
|
-
* `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
|
|
69
|
-
* `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
|
|
70
|
-
* {@link OperationEventTypes}.
|
|
71
|
-
*/
|
|
72
|
-
export declare const OperationRoutes: {
|
|
73
|
-
readonly List: "LIST";
|
|
74
|
-
readonly Cancel: "CANCEL";
|
|
75
|
-
readonly ClearFinished: "CLEAR_FINISHED";
|
|
76
|
-
readonly Resume: "RESUME";
|
|
77
|
-
/** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
|
|
78
|
-
readonly Dismiss: "DISMISS";
|
|
79
|
-
/**
|
|
80
|
-
* Ask the owning module to wait a running operation by id (generic-library audit finding 3,
|
|
81
|
-
* renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
|
|
82
|
-
* Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
|
|
83
|
-
* and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
|
|
84
|
-
*/
|
|
85
|
-
readonly Wait: "WAIT";
|
|
86
|
-
};
|
|
87
|
-
/**
|
|
88
|
-
* Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
|
|
89
|
-
* `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
|
|
90
|
-
*/
|
|
91
|
-
export declare const OperationModuleName = "OPERATIONS";
|
|
92
|
-
/**
|
|
93
|
-
* Mirrors `Shenora.Ipc.OperationLabel` — human-facing text the HOST never renders itself: an
|
|
94
|
-
* untranslated fallback plus an app i18n key and interpolation parameters (headless, D13).
|
|
95
|
-
*/
|
|
96
|
-
export interface OperationLabel {
|
|
97
|
-
text?: string;
|
|
98
|
-
key?: string;
|
|
99
|
-
parameters?: Record<string, string>;
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Mirrors `Shenora.Ipc.OperationProgress` — how far a tracked operation has gotten, in the APP's own
|
|
103
|
-
* unit, never a kit-assumed percent (generic-library audit, before publish: percent is not the
|
|
104
|
-
* mechanism, it is one way an app happens to measure). `total` is the denominator when one is known;
|
|
105
|
-
* `undefined` means there is NO known total — an absolute count with nothing to divide by (bytes
|
|
106
|
-
* streamed so far off a chunked response, say), never zero. `unit` is app-defined, like `kind`
|
|
107
|
-
* (`'bytes'`, `'files'`, `'percent'`) — the kit never interprets it and ships no percent helper: render
|
|
108
|
-
* a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
|
|
109
|
-
* is the consumer's own policy (see the README example).
|
|
110
|
-
*/
|
|
111
|
-
export interface OperationProgress {
|
|
112
|
-
value: number;
|
|
113
|
-
total?: number;
|
|
114
|
-
unit?: string;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Mirrors `Shenora.Ipc.OperationInfo` — a full snapshot of one tracked operation. Every lifecycle
|
|
118
|
-
* transition (start, progress, terminal) publishes one of these under `OPERATION_UPDATED`, so the
|
|
119
|
-
* client folds by `id`: last write wins, with no cross-type ordering hazard.
|
|
120
|
-
*/
|
|
121
|
-
export interface OperationInfo {
|
|
122
|
-
id: string;
|
|
123
|
-
module: string;
|
|
124
|
-
kind: string;
|
|
125
|
-
scope?: string;
|
|
126
|
-
status: OperationStatus;
|
|
127
|
-
progress?: OperationProgress;
|
|
128
|
-
title?: OperationLabel;
|
|
129
|
-
detail?: OperationLabel;
|
|
130
|
-
/** Why the operation is `'waiting'` — an app-defined string, like `kind`; the kit never interprets it. */
|
|
131
|
-
waitReason?: string;
|
|
132
|
-
error?: IpcError;
|
|
133
|
-
cancellable: boolean;
|
|
134
|
-
startedAt: string;
|
|
135
|
-
finishedAt?: string;
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* State behind {@link useShenoraOperations}. Mirrors the host's THREE bands (design §5A.2):
|
|
139
|
-
*
|
|
140
|
-
* | Band | Getter | Exits |
|
|
141
|
-
* |---|---|---|
|
|
142
|
-
* | Active | {@link running} | complete / fail / cancel / wait |
|
|
143
|
-
* | Waiting — stopped, resumable, awaiting a decision | {@link waiting} | resume / dismiss / complete / fail |
|
|
144
|
-
* | Terminal | {@link finished} | — (prunable via `clearFinished`) |
|
|
145
|
-
*
|
|
146
|
-
* `waiting` is now the WHOLE band, not a union of two getters — the host's `OperationStatus` carries
|
|
147
|
-
* only one waiting value (the former `paused`/`interrupted` pair collapsed into it, since every host
|
|
148
|
-
* transition already treated them as one band). A waiting entry is a pending offer, not finished
|
|
149
|
-
* history: the host never prunes it on its own, and only `Resume` or `Dismiss` removes it. Every
|
|
150
|
-
* getter here is DERIVED from `byId` on every read — never a second copy a fold has to remember to
|
|
151
|
-
* keep in sync. `byId` itself is the only thing any reducer here ever writes.
|
|
152
|
-
*/
|
|
153
|
-
export interface OperationsState {
|
|
154
|
-
byId: Record<string, OperationInfo>;
|
|
155
|
-
/** Every currently-running operation, in `byId` order. */
|
|
156
|
-
readonly running: OperationInfo[];
|
|
157
|
-
/**
|
|
158
|
-
* The WAITING band (design §5A.2): stopped, resumable, awaiting a decision — exactly what
|
|
159
|
-
* `Dismiss`/`RequestResume` both accept, so a status bar can render "needs you" as one bucket.
|
|
160
|
-
* Every entry here reached the band the same way (a live `Wait()` on the host), so there is no
|
|
161
|
-
* sub-case to distinguish — see {@link OperationStatuses.Waiting}.
|
|
162
|
-
*/
|
|
163
|
-
readonly waiting: OperationInfo[];
|
|
164
|
-
/** Every operation that reached a terminal status (completed/failed/cancelled). */
|
|
165
|
-
readonly finished: OperationInfo[];
|
|
166
|
-
}
|
|
167
|
-
/** Fire-and-forget actions exposed on {@link useShenoraOperations}, routed to `OperationsFacade`. */
|
|
168
|
-
export interface OperationsActions {
|
|
169
|
-
/** `CANCEL { operationId }` — the app-level cancel route `ipc-contracts` prescribes. */
|
|
170
|
-
cancel: (operationId: string) => string;
|
|
171
|
-
/**
|
|
172
|
-
* `DISMISS { operationId }` — decline a pending `waiting` offer (design §5A.3), mirroring
|
|
173
|
-
* {@link cancel}'s shape. No optimistic local prune: the host's `Dismiss` transitions the entry to
|
|
174
|
-
* `cancelled` and publishes an ordinary `OPERATION_UPDATED` snapshot for it (same as a real
|
|
175
|
-
* cancel), so the store already folds the result from the wire.
|
|
176
|
-
*/
|
|
177
|
-
dismiss: (operationId: string) => string;
|
|
178
|
-
/**
|
|
179
|
-
* `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
|
|
180
|
-
* configured scope (generic-library audit finding 1) so a scoped store's "clear completed" cannot
|
|
181
|
-
* wipe another scope's history host-side. No local mutation here: the host's
|
|
182
|
-
* `OPERATION_REMOVED` (finding 4) is the only thing that removes a row from this store now — see
|
|
183
|
-
* {@link OperationEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
|
|
184
|
-
* entry, added because removals had no wire event at all; that guess is retired now that one exists.
|
|
185
|
-
*/
|
|
186
|
-
clearFinished: () => string;
|
|
187
|
-
/**
|
|
188
|
-
* `RESUME { operationId }` — ask the host to resume a waiting operation, mirroring {@link wait}.
|
|
189
|
-
* No local mutation: asking never changes state by itself — the owning module's own
|
|
190
|
-
* `IOperation.Resume` publishes the `running` transition, and the store folds it from the wire like
|
|
191
|
-
* any other `OPERATION_UPDATED`.
|
|
192
|
-
*
|
|
193
|
-
* This action carried an optimistic local prune through two designs and was the source of the
|
|
194
|
-
* release's only Critical — it deleted a row the host deliberately kept, making a still-waiting
|
|
195
|
-
* operation unreachable. The prune existed because the host's `RequestResume` was ASYMMETRIC
|
|
196
|
-
* (removing crash-checkpoint entries, keeping live ones). The 0.2.0 design pass cut the checkpoint
|
|
197
|
-
* half, so the asymmetry is gone at the source and there is nothing left for a client to mirror.
|
|
198
|
-
*/
|
|
199
|
-
resume: (operationId: string) => string;
|
|
200
|
-
/**
|
|
201
|
-
* `WAIT { operationId }` — ask the owning module to wait a running operation (generic-library
|
|
202
|
-
* audit finding 3, renamed from `pause`), mirroring {@link dismiss}'s shape. No optimistic local
|
|
203
|
-
* prune: asking never changes the state by itself — the owning module's own `IOperation.Wait` is
|
|
204
|
-
* what publishes the `waiting` transition, and the store folds it from the wire like any other
|
|
205
|
-
* `OPERATION_UPDATED`.
|
|
206
|
-
*/
|
|
207
|
-
wait: (operationId: string) => string;
|
|
208
|
-
}
|
|
209
|
-
/** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createOperationsStore}. */
|
|
210
|
-
export interface OperationsStoreOptions {
|
|
211
|
-
/**
|
|
212
|
-
* The request/event module this store talks to. Must match the host's
|
|
213
|
-
* `OperationRegistryOptions.ModuleName` — default `'OPERATIONS'` on both sides — when an app
|
|
214
|
-
* renamed it to avoid a collision with one of its own module names (the duplicate-module guard
|
|
215
|
-
* `OperationsFacade`'s own docs describe). A store bound to the default name cannot reach a
|
|
216
|
-
* renamed host at all, which is exactly the gap this field closes.
|
|
217
|
-
*/
|
|
218
|
-
module?: string;
|
|
219
|
-
/**
|
|
220
|
-
* Optional app-defined scope, applied to THREE places so the store stays internally consistent:
|
|
221
|
-
* the bus subscription (only deltas whose event scope matches are folded), the actions' request
|
|
222
|
-
* envelope, and the initial `LIST` snapshot's payload (`OperationsFacade` reads its scope filter
|
|
223
|
-
* from the payload, not the envelope — see `OperationsFacade.RouteMessageAsync`). Threading it
|
|
224
|
-
* into only the first two would load every scope on first subscribe and never remove the
|
|
225
|
-
* out-of-scope rows, since no delta for them ever arrives: a silent, permanent leak.
|
|
226
|
-
*/
|
|
227
|
-
scope?: string;
|
|
228
|
-
/** Test/multi-transport seams. Default: the shared bridge and event bus. */
|
|
229
|
-
bridge?: ShenoraBridge;
|
|
230
|
-
bus?: ShenoraEventBus;
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Build a store instance over the operations module — the factory {@link useShenoraOperations}
|
|
234
|
-
* itself is built from. Exposed (rather than only the ready-made hook) for the same reason
|
|
235
|
-
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
236
|
-
* (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
|
|
237
|
-
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
238
|
-
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
239
|
-
* stuck with the shared, unscoped default.
|
|
240
|
-
*/
|
|
241
|
-
export declare function createOperationsStore(options?: OperationsStoreOptions): ShenoraStore<OperationsState, OperationsActions>;
|
|
242
|
-
/**
|
|
243
|
-
* The client side of the operations primitive (design §4.6, `OperationsFacade` +
|
|
244
|
-
* `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
|
|
245
|
-
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
246
|
-
* because the host is authoritative (the store primitive's own late-mounter case is now
|
|
247
|
-
* host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
|
|
248
|
-
* bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
|
|
249
|
-
* (every entry in it reached the band the same way, so there is no sub-case to filter for; read
|
|
250
|
-
* `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
|
|
251
|
-
* {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
|
|
252
|
-
*
|
|
253
|
-
* Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
|
|
254
|
-
* IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
|
|
255
|
-
*/
|
|
256
|
-
export declare const useShenoraOperations: ShenoraStore<OperationsState, OperationsActions>;
|
package/dist/operations.js
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { createShenoraStore } from './store.js';
|
|
2
|
-
/**
|
|
3
|
-
* Mirrors `Shenora.Ipc.OperationStatus` (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_operation_status_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 OperationStatuses = {
|
|
10
|
-
Running: 'running',
|
|
11
|
-
Completed: 'completed',
|
|
12
|
-
Failed: 'failed',
|
|
13
|
-
Cancelled: 'cancelled',
|
|
14
|
-
/**
|
|
15
|
-
* Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
|
|
16
|
-
* (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
|
|
17
|
-
* one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
|
|
18
|
-
* a waiting entry is a pending offer, not something finished.
|
|
19
|
-
*
|
|
20
|
-
* Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
|
|
21
|
-
* body is parked, not gone, so there is always something to resume. Exits via the host's
|
|
22
|
-
* `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
|
|
23
|
-
* complete/fail.
|
|
24
|
-
*
|
|
25
|
-
* Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
|
|
26
|
-
* rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
|
|
27
|
-
* host transition already treated as one band, and the host once also accepted crash-checkpoint
|
|
28
|
-
* entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
|
|
29
|
-
* to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
|
|
30
|
-
* the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
|
|
31
|
-
*/
|
|
32
|
-
Waiting: 'waiting',
|
|
33
|
-
};
|
|
34
|
-
/**
|
|
35
|
-
* Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
|
|
36
|
-
* `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
|
|
37
|
-
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
38
|
-
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
39
|
-
*/
|
|
40
|
-
export const OperationEventTypes = {
|
|
41
|
-
Updated: 'OPERATION_UPDATED',
|
|
42
|
-
ResumeRequested: 'OPERATION_RESUME_REQUESTED',
|
|
43
|
-
/**
|
|
44
|
-
* A client asked to wait a running operation (generic-library audit finding 3, renamed from
|
|
45
|
-
* `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
|
|
46
|
-
* actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
|
|
47
|
-
* {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
|
|
48
|
-
* store.
|
|
49
|
-
*/
|
|
50
|
-
WaitRequested: 'OPERATION_WAIT_REQUESTED',
|
|
51
|
-
/**
|
|
52
|
-
* One or more operation ids left the host registry with no corresponding `Updated` snapshot —
|
|
53
|
-
* `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
|
|
54
|
-
* generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
|
|
55
|
-
* folds it by deleting those ids, which is what let the two hand-written optimistic prunes
|
|
56
|
-
* (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
|
|
57
|
-
* cannot diverge from what the host actually did, replacing two guesses that could.
|
|
58
|
-
*/
|
|
59
|
-
Removed: 'OPERATION_REMOVED',
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
|
|
63
|
-
* `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
|
|
64
|
-
* `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
|
|
65
|
-
* {@link OperationEventTypes}.
|
|
66
|
-
*/
|
|
67
|
-
export const OperationRoutes = {
|
|
68
|
-
List: 'LIST',
|
|
69
|
-
Cancel: 'CANCEL',
|
|
70
|
-
ClearFinished: 'CLEAR_FINISHED',
|
|
71
|
-
Resume: 'RESUME',
|
|
72
|
-
/** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
|
|
73
|
-
Dismiss: 'DISMISS',
|
|
74
|
-
/**
|
|
75
|
-
* Ask the owning module to wait a running operation by id (generic-library audit finding 3,
|
|
76
|
-
* renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
|
|
77
|
-
* Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
|
|
78
|
-
* and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
|
|
79
|
-
*/
|
|
80
|
-
Wait: 'WAIT',
|
|
81
|
-
};
|
|
82
|
-
/**
|
|
83
|
-
* Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
|
|
84
|
-
* `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
|
|
85
|
-
*/
|
|
86
|
-
export const OperationModuleName = 'OPERATIONS';
|
|
87
|
-
/**
|
|
88
|
-
* Statuses that count as finished history. Deliberately excludes `waiting` — a resumable operation
|
|
89
|
-
* (whether a live `Wait()` or a crash-announced checkpoint) is a pending offer, "distinct from
|
|
90
|
-
* finished history" (`Shenora.Ipc.OperationStatus.Waiting`), not a terminal outcome.
|
|
91
|
-
*/
|
|
92
|
-
const TERMINAL_STATUSES = new Set([
|
|
93
|
-
OperationStatuses.Completed,
|
|
94
|
-
OperationStatuses.Failed,
|
|
95
|
-
OperationStatuses.Cancelled,
|
|
96
|
-
]);
|
|
97
|
-
function index(list) {
|
|
98
|
-
const byId = {};
|
|
99
|
-
for (const operation of list)
|
|
100
|
-
byId[operation.id] = operation;
|
|
101
|
-
return byId;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* The one place `running`/`waiting`/`finished` are computed — wrap `byId` here, nowhere else.
|
|
105
|
-
*/
|
|
106
|
-
function makeState(byId) {
|
|
107
|
-
return {
|
|
108
|
-
byId,
|
|
109
|
-
get running() {
|
|
110
|
-
return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Running);
|
|
111
|
-
},
|
|
112
|
-
get waiting() {
|
|
113
|
-
return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Waiting);
|
|
114
|
-
},
|
|
115
|
-
get finished() {
|
|
116
|
-
return Object.values(byId).filter((operation) => TERMINAL_STATUSES.has(operation.status));
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Build a store instance over the operations module — the factory {@link useShenoraOperations}
|
|
122
|
-
* itself is built from. Exposed (rather than only the ready-made hook) for the same reason
|
|
123
|
-
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
124
|
-
* (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
|
|
125
|
-
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
126
|
-
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
127
|
-
* stuck with the shared, unscoped default.
|
|
128
|
-
*/
|
|
129
|
-
export function createOperationsStore(options = {}) {
|
|
130
|
-
const module = options.module ?? OperationModuleName;
|
|
131
|
-
return createShenoraStore(module, {
|
|
132
|
-
initial: makeState({}),
|
|
133
|
-
// LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
|
|
134
|
-
// that mounts while work is already running gets it from here before folding any deltas.
|
|
135
|
-
// The payload carries `scope` so the initial load is filtered the SAME way the deltas are
|
|
136
|
-
// (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
|
|
137
|
-
// scoped store loads every scope once and then never sheds the out-of-scope rows.
|
|
138
|
-
snapshot: {
|
|
139
|
-
type: OperationRoutes.List,
|
|
140
|
-
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
141
|
-
apply: (_state, data) => makeState(index(data)),
|
|
142
|
-
},
|
|
143
|
-
on: {
|
|
144
|
-
// ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
|
|
145
|
-
// no ordering logic and no cross-type races.
|
|
146
|
-
[OperationEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
|
|
147
|
-
// The ONE removal delta (Finding 4, generic-library audit), replacing the two hand-written
|
|
148
|
-
// optimistic prunes `clearFinished`/`resume` used to carry (see their own docs below) — deletes
|
|
149
|
-
// exactly the ids the host named, regardless of status; an id this store never had is a no-op.
|
|
150
|
-
[OperationEventTypes.Removed]: (state, payload) => {
|
|
151
|
-
const byId = { ...state.byId };
|
|
152
|
-
for (const id of payload.operationIds)
|
|
153
|
-
delete byId[id];
|
|
154
|
-
return makeState(byId);
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
actions: ({ post }) => ({
|
|
158
|
-
cancel: (operationId) => post(OperationRoutes.Cancel, { payload: { operationId } }),
|
|
159
|
-
dismiss: (operationId) => post(OperationRoutes.Dismiss, { payload: { operationId } }),
|
|
160
|
-
wait: (operationId) => post(OperationRoutes.Wait, { payload: { operationId } }),
|
|
161
|
-
clearFinished: () =>
|
|
162
|
-
// Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
|
|
163
|
-
// key the LIST snapshot payload already carries above. No local mutation here any more
|
|
164
|
-
// (Finding 4): the host's OPERATION_REMOVED is the ONLY thing that removes a row now, which
|
|
165
|
-
// is also what makes the scope threading safe to add — nothing here can diverge from what
|
|
166
|
-
// the host actually cleared.
|
|
167
|
-
post(OperationRoutes.ClearFinished, {
|
|
168
|
-
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
169
|
-
}),
|
|
170
|
-
resume: (operationId) => post(OperationRoutes.Resume, { payload: { operationId } }),
|
|
171
|
-
}),
|
|
172
|
-
scope: options.scope,
|
|
173
|
-
bridge: options.bridge,
|
|
174
|
-
bus: options.bus,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* The client side of the operations primitive (design §4.6, `OperationsFacade` +
|
|
179
|
-
* `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
|
|
180
|
-
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
181
|
-
* because the host is authoritative (the store primitive's own late-mounter case is now
|
|
182
|
-
* host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
|
|
183
|
-
* bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
|
|
184
|
-
* (every entry in it reached the band the same way, so there is no sub-case to filter for; read
|
|
185
|
-
* `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
|
|
186
|
-
* {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
|
|
187
|
-
*
|
|
188
|
-
* Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
|
|
189
|
-
* IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
|
|
190
|
-
*/
|
|
191
|
-
export const useShenoraOperations = createOperationsStore();
|