@shenora/react 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/bridge.d.ts +146 -0
- package/dist/bridge.js +295 -0
- package/dist/devInterceptor.d.ts +50 -0
- package/dist/devInterceptor.js +92 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +15 -0
- package/dist/eventBus.d.ts +74 -0
- package/dist/eventBus.js +158 -0
- package/dist/hooks.d.ts +45 -0
- package/dist/hooks.js +69 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +33 -0
- package/dist/moduleService.d.ts +61 -0
- package/dist/moduleService.js +57 -0
- package/dist/store.d.ts +102 -0
- package/dist/store.js +150 -0
- package/dist/transport.d.ts +18 -0
- package/dist/transport.js +26 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +53 -0
- package/dist/useDropZone.d.ts +56 -0
- package/dist/useDropZone.js +183 -0
- package/dist/windowCommands.d.ts +80 -0
- package/dist/windowCommands.js +94 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jiarong Gu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @shenora/react
|
|
2
|
+
|
|
3
|
+
React client for [Shenora](https://github.com/JiarongGu/Shenora) desktop hosts (.NET + WinForms +
|
|
4
|
+
WebView2). The typed bridge between a React frontend and the Shenora host: correlated `invoke`
|
|
5
|
+
with timeouts and structured errors, the event hub host notifications stream into, typed module
|
|
6
|
+
services, React hooks, and a pluggable transport with a browser fallback so the UI can be
|
|
7
|
+
developed in a plain browser. Headless by design — no UI components, bring your own design
|
|
8
|
+
system. Versioned in lockstep with the `Shenora.*` NuGet packages.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
getBridge, useShenoraEvent, useShenoraQuery, BaseModuleService, createShenoraStore,
|
|
13
|
+
} from '@shenora/react';
|
|
14
|
+
|
|
15
|
+
// Once at startup, after your listeners are attached: it starts notification delivery (anything the
|
|
16
|
+
// host buffered arrives in the first batch). Drop zones need no particular ordering against it — the
|
|
17
|
+
// host clears them when a new DOCUMENT loads, not on this handshake.
|
|
18
|
+
await getBridge().notifyReady();
|
|
19
|
+
|
|
20
|
+
// a typed service per backend module:
|
|
21
|
+
interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
22
|
+
class NoteService extends BaseModuleService<NoteRequests> {
|
|
23
|
+
constructor() { super('NOTES'); }
|
|
24
|
+
getAll() { return this.send<Note[]>('GET_ALL'); }
|
|
25
|
+
add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// in components:
|
|
29
|
+
const { data, loading, refetch } = useShenoraQuery<Note[]>('NOTES', 'GET_ALL');
|
|
30
|
+
useShenoraEvent<Note>('NOTES', 'ADDED', (note) => refetch());
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Failed calls reject with `OperationError` — a structured `code` (an i18n key: translate
|
|
34
|
+
`errors.{code}`) plus interpolation `parameters`, never raw host exception text.
|
|
35
|
+
|
|
36
|
+
### Long-running work: post, then read a store
|
|
37
|
+
|
|
38
|
+
`invoke` awaits a correlated reply and carries a timeout, and its handler's synchronous segment runs
|
|
39
|
+
on the host's **UI thread** — so it is for calls that are quick and UI-thread-safe. Everything else
|
|
40
|
+
posts and streams results back as events:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const useDeploy = createShenoraStore('DEPLOY', {
|
|
44
|
+
initial: { status: 'idle', lines: [] as string[] },
|
|
45
|
+
// Loaded on the FIRST subscriber, so a component that mounts mid-run isn't empty:
|
|
46
|
+
// events it missed cannot be replayed.
|
|
47
|
+
snapshot: { type: 'GET_STATE', apply: (s, d) => ({ ...s, ...(d as object) }) },
|
|
48
|
+
on: {
|
|
49
|
+
PROGRESS: (s, p: { line: string }) => ({ ...s, lines: [...s.lines, p.line] }),
|
|
50
|
+
ENDED: (s, p: { ok: boolean }) => ({ ...s, status: p.ok ? 'done' : 'failed' }),
|
|
51
|
+
},
|
|
52
|
+
actions: ({ post }) => ({ start: (cfg: unknown) => post('START', { payload: cfg }) }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// any number of components share ONE subscription:
|
|
56
|
+
const status = useDeploy((s) => s.status);
|
|
57
|
+
useDeploy.actions.start({ env: 'prod' });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Use the **store for shared or long-lived state** and **`useShenoraEvent` for a one-off reaction in a
|
|
61
|
+
single component**. A failed `post` has no promise to reject, so it is reported through the bridge's
|
|
62
|
+
`onPostError` rather than vanishing — it is bridge-wide, so wire it once at startup
|
|
63
|
+
(`getBridge()` takes no options):
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Observing the whole stream
|
|
70
|
+
|
|
71
|
+
`useShenoraEvent` and `createShenoraStore` listen for an exact `(module, type)`. When the vocabulary
|
|
72
|
+
isn't knowable up front — plug-in-contributed events, a diagnostics tap, or an adoption shim keeping
|
|
73
|
+
a legacy "every host message" handler alive while features migrate one at a time — subscribe broadly
|
|
74
|
+
instead. Both mirror the host's `IEventBus` and return an unsubscribe:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const off = eventBus.subscribeToAll((event) => log.debug(event.module, event.type, event.payload));
|
|
78
|
+
eventBus.subscribeToModule('DEPLOY', (event) => audit(event)); // every type from one module
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Delivery is narrowest-first — exact pair, then module, then catch-all — so a broad observer never
|
|
82
|
+
runs ahead of the feature code it is observing. Prefer `subscribe` when you know the pair: a
|
|
83
|
+
catch-all wakes for every event on the bus.
|
|
84
|
+
|
|
85
|
+
Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
|
|
86
|
+
`import.meta.env.DEV`) to answer requests with canned data. Other shells (WebSocket,
|
|
87
|
+
mobile/Capacitor) implement the small `ShenoraTransport` seam and speak the same envelopes.
|
|
88
|
+
For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
|
|
89
|
+
and exposes `window.__shenora.call()/waitEvent()`.
|
|
90
|
+
|
|
91
|
+
MIT © Jiarong Gu
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type ShenoraEventBus } from './eventBus.js';
|
|
2
|
+
import { type ShenoraTransport } from './transport.js';
|
|
3
|
+
import { type IpcError, type IpcRequest } from './types.js';
|
|
4
|
+
/** Inputs for {@link ShenoraBridge}. */
|
|
5
|
+
export interface ShenoraBridgeOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The channel to the host. Default: the WebView2 postMessage transport when running inside a
|
|
8
|
+
* host, else null (browser). Supply your own for other shells — a WebSocket, a mobile shell's
|
|
9
|
+
* native channel (D16) — or a scripted fake for tests/preview harnesses.
|
|
10
|
+
*/
|
|
11
|
+
transport?: ShenoraTransport | null;
|
|
12
|
+
/** The event bus host notifications are unbundled into. Default: the shared bus. */
|
|
13
|
+
eventBus?: ShenoraEventBus;
|
|
14
|
+
/** Per-request timeout in ms when the call doesn't set one. Family default: 30 000. */
|
|
15
|
+
defaultTimeoutMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Pure-UI development seam: answers requests when NO transport exists (plain browser tab —
|
|
18
|
+
* component/layout work without the desktop host). Return the response data (or a promise;
|
|
19
|
+
* throw to reject). Generalized from the source app's hardcoded dev mocks: the mocks are app
|
|
20
|
+
* schema, so the app supplies them — gate with `import.meta.env.DEV` at the call site so
|
|
21
|
+
* production stays hard-failing.
|
|
22
|
+
*/
|
|
23
|
+
fallback?: (request: IpcRequest) => unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Where a FAILED {@link ShenoraBridge.post} is reported. Default: `console.error`.
|
|
26
|
+
*
|
|
27
|
+
* A one-way send has no promise to reject, so without this its failures would be invisible — and an
|
|
28
|
+
* unmatched response is dropped silently by the inbound handler, which is exactly how a feature
|
|
29
|
+
* "just stops working" with nothing to grep for. Route it into the app's logger/toast instead.
|
|
30
|
+
*/
|
|
31
|
+
onPostError?: (error: PostFailure) => void;
|
|
32
|
+
/**
|
|
33
|
+
* How many unawaited {@link ShenoraBridge.post} ids to remember for error reporting. Default 256.
|
|
34
|
+
* Capped (drop-oldest) so a host that never answers cannot grow the set without bound — the same
|
|
35
|
+
* shape as the host's own bounded notification queue. Evicting an id only loses its error report.
|
|
36
|
+
*/
|
|
37
|
+
maxTrackedPosts?: number;
|
|
38
|
+
}
|
|
39
|
+
/** A one-way {@link ShenoraBridge.post} whose host handler answered with a failure. */
|
|
40
|
+
export interface PostFailure {
|
|
41
|
+
module: string;
|
|
42
|
+
type: string;
|
|
43
|
+
/** The request id, so it can be tied to a host log line. */
|
|
44
|
+
id: string;
|
|
45
|
+
error: IpcError;
|
|
46
|
+
}
|
|
47
|
+
/** Per-call inputs for {@link ShenoraBridge.invoke}. */
|
|
48
|
+
export interface InvokeOptions<TPayload = unknown> {
|
|
49
|
+
payload?: TPayload;
|
|
50
|
+
/** Optional app-defined routing scope. */
|
|
51
|
+
scope?: string;
|
|
52
|
+
/** Overrides the bridge's default timeout. */
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
/** Per-call inputs for {@link ShenoraBridge.post}. */
|
|
56
|
+
export interface PostOptions<TPayload = unknown> {
|
|
57
|
+
payload?: TPayload;
|
|
58
|
+
/** Optional app-defined routing scope. */
|
|
59
|
+
scope?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The client side of the Shenora IPC contract, ported from the primary desktop sibling:
|
|
63
|
+
* correlated request/response over a pluggable transport, category routing of host messages
|
|
64
|
+
* (`ipc` → resolve the pending call, `notification` → unbundle the batch into the event bus),
|
|
65
|
+
* per-request timeout, the ready handshake, and a browser fallback seam for pure-UI development.
|
|
66
|
+
*
|
|
67
|
+
* Most apps use the lazy default instance via {@link getBridge}/{@link configureBridge}; create
|
|
68
|
+
* instances directly for tests or multi-transport setups.
|
|
69
|
+
*/
|
|
70
|
+
export declare class ShenoraBridge {
|
|
71
|
+
private readonly transport;
|
|
72
|
+
private readonly eventBus;
|
|
73
|
+
private readonly defaultTimeoutMs;
|
|
74
|
+
private readonly fallback?;
|
|
75
|
+
private readonly pending;
|
|
76
|
+
private readonly unawaited;
|
|
77
|
+
private readonly maxTrackedPosts;
|
|
78
|
+
private readonly onPostError;
|
|
79
|
+
private readonly unsubscribe?;
|
|
80
|
+
private disposed;
|
|
81
|
+
constructor(options?: ShenoraBridgeOptions);
|
|
82
|
+
/**
|
|
83
|
+
* True when this bridge can actually send: a transport to a host exists AND the bridge has not been
|
|
84
|
+
* disposed. It used to ignore `disposed`, so a stale reference to a bridge that `configureBridge`
|
|
85
|
+
* replaced still reported itself available while every `invoke` on it rejected with `NO_TRANSPORT` —
|
|
86
|
+
* the exact case the disposed check in `invoke` exists for (P5.5 H2).
|
|
87
|
+
*/
|
|
88
|
+
get isAvailable(): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Send a request and await its typed response data. A failed response rejects with the
|
|
91
|
+
* structured {@link OperationError} (code + parameters); no response within the timeout
|
|
92
|
+
* rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
|
|
93
|
+
*/
|
|
94
|
+
invoke<TData = unknown, TPayload = unknown>(module: string, type: string, options?: InvokeOptions<TPayload>): Promise<TData>;
|
|
95
|
+
/**
|
|
96
|
+
* Send WITHOUT awaiting a reply, and return the request id.
|
|
97
|
+
*
|
|
98
|
+
* This is the default shape for a desktop shell, and {@link invoke} is the special case — see
|
|
99
|
+
* `docs/2026-07-31-shenora-oneway-ipc-design.md`. Two reasons: a correlated call carries a deadline
|
|
100
|
+
* (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
|
|
101
|
+
* design, because the dispatch pipeline preserves the caller's synchronization context so facades
|
|
102
|
+
* can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
|
|
103
|
+
* window commands are the model — and post everything else, streaming results back as notifications.
|
|
104
|
+
*
|
|
105
|
+
* ⚠ Posting is only HALF of freeing the UI thread. The host still dispatches on the UI thread
|
|
106
|
+
* whether or not the client awaits, so a handler that does heavy work synchronously stalls the
|
|
107
|
+
* window either way. The other half is the host's: return from the route immediately and stream.
|
|
108
|
+
*
|
|
109
|
+
* Failures are not silent. There is no promise to reject, so a failed response is reported through
|
|
110
|
+
* `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
|
|
111
|
+
* otherwise is. Nothing is queued and no timer is set, so there is nothing to leak and no deadline.
|
|
112
|
+
*
|
|
113
|
+
* No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
|
|
114
|
+
* unlike `invoke`, there is no caller waiting to be told.
|
|
115
|
+
*/
|
|
116
|
+
post<TPayload = unknown>(module: string, type: string, options?: PostOptions<TPayload>): string;
|
|
117
|
+
/**
|
|
118
|
+
* The ready handshake: tells the host the page's listeners are attached, which starts
|
|
119
|
+
* notification delivery (events buffered host-side arrive in the first batch). Call once the
|
|
120
|
+
* app shell has subscribed — a reloaded page calls it again on its fresh startup, which is
|
|
121
|
+
* also the host's cue to reset per-page state. No-transport is a silent no-op so browser dev
|
|
122
|
+
* doesn't error.
|
|
123
|
+
*
|
|
124
|
+
* Ordering used to matter here and no longer does for drop zones: the host cleared the previous
|
|
125
|
+
* page's overlays on this handshake, so a `REGISTER` sent before `READY` was wiped even though it
|
|
126
|
+
* was acked. `DropZoneManager` now clears on DOCUMENT CHANGE instead, which cannot race the client.
|
|
127
|
+
* The general point still stands for any per-page state YOUR host resets here — a reset keyed on
|
|
128
|
+
* the handshake races anything the page sends before it, and in React that is structural rather
|
|
129
|
+
* than a mistake, because CHILD effects run before PARENT effects.
|
|
130
|
+
*
|
|
131
|
+
* The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
|
|
132
|
+
* `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
|
|
133
|
+
* a silent console error.
|
|
134
|
+
*/
|
|
135
|
+
notifyReady<TPayload = unknown>(payload?: TPayload): Promise<void>;
|
|
136
|
+
/** Reject everything in flight and detach from the transport. */
|
|
137
|
+
dispose(): void;
|
|
138
|
+
private onHostMessage;
|
|
139
|
+
}
|
|
140
|
+
/** The lazy default bridge (created with default options on first use). */
|
|
141
|
+
export declare function getBridge(): ShenoraBridge;
|
|
142
|
+
/**
|
|
143
|
+
* Create the default bridge with options — call once at startup, before anything uses
|
|
144
|
+
* {@link getBridge}. Calling again (tests, HMR) disposes the previous default first.
|
|
145
|
+
*/
|
|
146
|
+
export declare function configureBridge(options: ShenoraBridgeOptions): ShenoraBridge;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { OperationError } from './errors.js';
|
|
2
|
+
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
3
|
+
import { randomId } from './internal.js';
|
|
4
|
+
import { createWebView2Transport } from './transport.js';
|
|
5
|
+
import { HANDSHAKE_MODULE, HANDSHAKE_TYPE, IpcCategories, IpcErrorCodes, } from './types.js';
|
|
6
|
+
const newId = () => randomId();
|
|
7
|
+
/** A promise-like: only these need racing against a timeout — a plain value has already settled. */
|
|
8
|
+
const isThenable = (value) => typeof value === 'object' && value !== null
|
|
9
|
+
&& typeof value.then === 'function';
|
|
10
|
+
/**
|
|
11
|
+
* The client side of the Shenora IPC contract, ported from the primary desktop sibling:
|
|
12
|
+
* correlated request/response over a pluggable transport, category routing of host messages
|
|
13
|
+
* (`ipc` → resolve the pending call, `notification` → unbundle the batch into the event bus),
|
|
14
|
+
* per-request timeout, the ready handshake, and a browser fallback seam for pure-UI development.
|
|
15
|
+
*
|
|
16
|
+
* Most apps use the lazy default instance via {@link getBridge}/{@link configureBridge}; create
|
|
17
|
+
* instances directly for tests or multi-transport setups.
|
|
18
|
+
*/
|
|
19
|
+
export class ShenoraBridge {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.pending = new Map();
|
|
22
|
+
// Ids of one-way sends, kept ONLY so a failed response can be reported instead of vanishing.
|
|
23
|
+
// Insertion-ordered and capped: a Map is used for its ordered keys, not for the values.
|
|
24
|
+
this.unawaited = new Map();
|
|
25
|
+
this.disposed = false;
|
|
26
|
+
this.transport = options.transport !== undefined ? options.transport : createWebView2Transport();
|
|
27
|
+
this.eventBus = options.eventBus ?? defaultEventBus;
|
|
28
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 30000;
|
|
29
|
+
this.fallback = options.fallback;
|
|
30
|
+
this.maxTrackedPosts = options.maxTrackedPosts ?? 256;
|
|
31
|
+
this.onPostError = options.onPostError
|
|
32
|
+
?? ((failure) => console.error(`[shenora] ${failure.module}.${failure.type} (post) failed: ${failure.error.code}`, failure.error));
|
|
33
|
+
this.unsubscribe = this.transport?.subscribe((message) => this.onHostMessage(message));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when this bridge can actually send: a transport to a host exists AND the bridge has not been
|
|
37
|
+
* disposed. It used to ignore `disposed`, so a stale reference to a bridge that `configureBridge`
|
|
38
|
+
* replaced still reported itself available while every `invoke` on it rejected with `NO_TRANSPORT` —
|
|
39
|
+
* the exact case the disposed check in `invoke` exists for (P5.5 H2).
|
|
40
|
+
*/
|
|
41
|
+
get isAvailable() {
|
|
42
|
+
return !this.disposed && this.transport !== null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Send a request and await its typed response data. A failed response rejects with the
|
|
46
|
+
* structured {@link OperationError} (code + parameters); no response within the timeout
|
|
47
|
+
* rejects with code `TIMEOUT`; no transport and no fallback rejects with `NO_TRANSPORT`.
|
|
48
|
+
*/
|
|
49
|
+
invoke(module, type, options = {}) {
|
|
50
|
+
if (this.disposed) {
|
|
51
|
+
// Fail fast: the transport subscription is gone, so a response could never correlate —
|
|
52
|
+
// without this the call would burn the full timeout (stale references after
|
|
53
|
+
// configureBridge replaced the default are the typical way here).
|
|
54
|
+
return Promise.reject(new OperationError({
|
|
55
|
+
code: IpcErrorCodes.noTransport,
|
|
56
|
+
message: `Bridge disposed — ${module}.${type} cannot be sent.`,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
const request = {
|
|
60
|
+
id: newId(),
|
|
61
|
+
module,
|
|
62
|
+
type,
|
|
63
|
+
scope: options.scope,
|
|
64
|
+
payload: options.payload,
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
68
|
+
if (!this.transport) {
|
|
69
|
+
if (this.fallback) {
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = this.fallback(request);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return Promise.reject(error);
|
|
76
|
+
}
|
|
77
|
+
// A fallback may be async (a scripted preview harness commonly is), and this path used to
|
|
78
|
+
// bypass the timeout entirely — so a fallback that never settled hung the caller forever, with
|
|
79
|
+
// none of the diagnostics the real path gives (P5.5 H2). Only a thenable needs racing; a plain
|
|
80
|
+
// value is already settled.
|
|
81
|
+
if (!isThenable(result))
|
|
82
|
+
return Promise.resolve(result);
|
|
83
|
+
return Promise.race([
|
|
84
|
+
Promise.resolve(result),
|
|
85
|
+
new Promise((_, reject) => {
|
|
86
|
+
setTimeout(() => reject(new OperationError({
|
|
87
|
+
code: IpcErrorCodes.timeout,
|
|
88
|
+
message: `${module}.${type} timed out after ${timeoutMs} ms (in the configured fallback).`,
|
|
89
|
+
parameters: { module, type },
|
|
90
|
+
})), timeoutMs);
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
return Promise.reject(new OperationError({
|
|
95
|
+
code: IpcErrorCodes.noTransport,
|
|
96
|
+
message: `No transport for ${module}.${type} — not inside a Shenora host, and no fallback is configured.`,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
this.pending.delete(request.id);
|
|
102
|
+
reject(new OperationError({
|
|
103
|
+
code: IpcErrorCodes.timeout,
|
|
104
|
+
message: `${module}.${type} timed out after ${timeoutMs} ms.`,
|
|
105
|
+
parameters: { module, type },
|
|
106
|
+
}));
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
this.pending.set(request.id, {
|
|
109
|
+
resolve: (data) => resolve(data),
|
|
110
|
+
reject,
|
|
111
|
+
timer,
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
this.transport.post(JSON.stringify(request));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
this.pending.delete(request.id);
|
|
119
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Send WITHOUT awaiting a reply, and return the request id.
|
|
125
|
+
*
|
|
126
|
+
* This is the default shape for a desktop shell, and {@link invoke} is the special case — see
|
|
127
|
+
* `docs/2026-07-31-shenora-oneway-ipc-design.md`. Two reasons: a correlated call carries a deadline
|
|
128
|
+
* (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
|
|
129
|
+
* design, because the dispatch pipeline preserves the caller's synchronization context so facades
|
|
130
|
+
* can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
|
|
131
|
+
* window commands are the model — and post everything else, streaming results back as notifications.
|
|
132
|
+
*
|
|
133
|
+
* ⚠ Posting is only HALF of freeing the UI thread. The host still dispatches on the UI thread
|
|
134
|
+
* whether or not the client awaits, so a handler that does heavy work synchronously stalls the
|
|
135
|
+
* window either way. The other half is the host's: return from the route immediately and stream.
|
|
136
|
+
*
|
|
137
|
+
* Failures are not silent. There is no promise to reject, so a failed response is reported through
|
|
138
|
+
* `onPostError` (default `console.error`) rather than being dropped the way an unmatched response
|
|
139
|
+
* otherwise is. Nothing is queued and no timer is set, so there is nothing to leak and no deadline.
|
|
140
|
+
*
|
|
141
|
+
* No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
|
|
142
|
+
* unlike `invoke`, there is no caller waiting to be told.
|
|
143
|
+
*/
|
|
144
|
+
post(module, type, options = {}) {
|
|
145
|
+
const request = {
|
|
146
|
+
id: newId(),
|
|
147
|
+
module,
|
|
148
|
+
type,
|
|
149
|
+
scope: options.scope,
|
|
150
|
+
payload: options.payload,
|
|
151
|
+
timestamp: new Date().toISOString(),
|
|
152
|
+
};
|
|
153
|
+
if (this.disposed || !this.transport)
|
|
154
|
+
return request.id;
|
|
155
|
+
// Remember it ONLY to report a failure. Drop-oldest at the cap so a host that never answers
|
|
156
|
+
// cannot grow this without bound; an evicted id simply loses its error report.
|
|
157
|
+
if (this.unawaited.size >= this.maxTrackedPosts) {
|
|
158
|
+
const oldest = this.unawaited.keys().next();
|
|
159
|
+
if (!oldest.done)
|
|
160
|
+
this.unawaited.delete(oldest.value);
|
|
161
|
+
}
|
|
162
|
+
this.unawaited.set(request.id, { module, type });
|
|
163
|
+
try {
|
|
164
|
+
this.transport.post(JSON.stringify(request));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
this.unawaited.delete(request.id);
|
|
168
|
+
this.onPostError({
|
|
169
|
+
module,
|
|
170
|
+
type,
|
|
171
|
+
id: request.id,
|
|
172
|
+
error: {
|
|
173
|
+
code: IpcErrorCodes.noTransport,
|
|
174
|
+
message: error instanceof Error ? error.message : String(error),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return request.id;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The ready handshake: tells the host the page's listeners are attached, which starts
|
|
182
|
+
* notification delivery (events buffered host-side arrive in the first batch). Call once the
|
|
183
|
+
* app shell has subscribed — a reloaded page calls it again on its fresh startup, which is
|
|
184
|
+
* also the host's cue to reset per-page state. No-transport is a silent no-op so browser dev
|
|
185
|
+
* doesn't error.
|
|
186
|
+
*
|
|
187
|
+
* Ordering used to matter here and no longer does for drop zones: the host cleared the previous
|
|
188
|
+
* page's overlays on this handshake, so a `REGISTER` sent before `READY` was wiped even though it
|
|
189
|
+
* was acked. `DropZoneManager` now clears on DOCUMENT CHANGE instead, which cannot race the client.
|
|
190
|
+
* The general point still stands for any per-page state YOUR host resets here — a reset keyed on
|
|
191
|
+
* the handshake races anything the page sends before it, and in React that is structural rather
|
|
192
|
+
* than a mistake, because CHILD effects run before PARENT effects.
|
|
193
|
+
*
|
|
194
|
+
* The returned promise REJECTS on a failed handshake (disposed bridge, timeout). Handle it —
|
|
195
|
+
* `void bridge.notifyReady()` turns that into an unhandled rejection, which in a WebView2 page is
|
|
196
|
+
* a silent console error.
|
|
197
|
+
*/
|
|
198
|
+
async notifyReady(payload) {
|
|
199
|
+
if (!this.transport)
|
|
200
|
+
return;
|
|
201
|
+
await this.invoke(HANDSHAKE_MODULE, HANDSHAKE_TYPE, { payload });
|
|
202
|
+
}
|
|
203
|
+
/** Reject everything in flight and detach from the transport. */
|
|
204
|
+
dispose() {
|
|
205
|
+
if (this.disposed)
|
|
206
|
+
return;
|
|
207
|
+
this.disposed = true;
|
|
208
|
+
this.unsubscribe?.();
|
|
209
|
+
for (const [id, entry] of this.pending) {
|
|
210
|
+
clearTimeout(entry.timer);
|
|
211
|
+
entry.reject(new OperationError({ code: IpcErrorCodes.noTransport, message: 'Bridge disposed.' }));
|
|
212
|
+
this.pending.delete(id);
|
|
213
|
+
}
|
|
214
|
+
// Unawaited ids are pure bookkeeping with nothing to settle — drop them so a disposed bridge
|
|
215
|
+
// holds no references (this is the instance `configureBridge` replaces).
|
|
216
|
+
this.unawaited.clear();
|
|
217
|
+
}
|
|
218
|
+
onHostMessage(message) {
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(message);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
console.error('[shenora] ignored unparseable host message:', error);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// A literal `null` is VALID JSON, so it survives the parse and then `parsed.category` threw a
|
|
228
|
+
// TypeError — out of a transport listener, i.e. an uncaught page error with no caller to catch it
|
|
229
|
+
// (P5.5 H2). Primitives (`"str"`, `123`, `true`) never threw because property access on them just
|
|
230
|
+
// yields undefined; null and only null did. Anything that isn't an object simply isn't ours.
|
|
231
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
232
|
+
return;
|
|
233
|
+
const envelope = parsed;
|
|
234
|
+
if (envelope.category === IpcCategories.ipc) {
|
|
235
|
+
const response = parsed;
|
|
236
|
+
if (typeof response.id !== 'string')
|
|
237
|
+
return;
|
|
238
|
+
const entry = this.pending.get(response.id);
|
|
239
|
+
if (!entry) {
|
|
240
|
+
// No pending call. Either this answers a one-way `post` — in which case a FAILURE must be
|
|
241
|
+
// surfaced, because there is no promise to reject and dropping it here is exactly how a
|
|
242
|
+
// feature "just stops working" with nothing to grep for — or it is a late/foreign response,
|
|
243
|
+
// which stays ignored.
|
|
244
|
+
const posted = this.unawaited.get(response.id);
|
|
245
|
+
if (posted) {
|
|
246
|
+
this.unawaited.delete(response.id);
|
|
247
|
+
if (!response.success) {
|
|
248
|
+
this.onPostError({
|
|
249
|
+
module: posted.module,
|
|
250
|
+
type: posted.type,
|
|
251
|
+
id: response.id,
|
|
252
|
+
error: response.error ?? { code: IpcErrorCodes.unknownError },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.pending.delete(response.id);
|
|
259
|
+
clearTimeout(entry.timer);
|
|
260
|
+
if (response.success) {
|
|
261
|
+
entry.resolve(response.data);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
entry.reject(new OperationError(response.error ?? { code: IpcErrorCodes.unknownError }));
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (envelope.category === IpcCategories.notification) {
|
|
269
|
+
// Always a batch (a single notification is a batch of one) — unbundle in order.
|
|
270
|
+
const batch = parsed;
|
|
271
|
+
if (!Array.isArray(batch.payload))
|
|
272
|
+
return;
|
|
273
|
+
for (const item of batch.payload) {
|
|
274
|
+
if (item && typeof item.module === 'string' && typeof item.type === 'string') {
|
|
275
|
+
this.eventBus.emit({ module: item.module, type: item.type, payload: item.payload, scope: item.scope });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Unknown categories: not ours — ignore (forward compatibility).
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
let defaultBridge;
|
|
283
|
+
/** The lazy default bridge (created with default options on first use). */
|
|
284
|
+
export function getBridge() {
|
|
285
|
+
return (defaultBridge ?? (defaultBridge = new ShenoraBridge()));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Create the default bridge with options — call once at startup, before anything uses
|
|
289
|
+
* {@link getBridge}. Calling again (tests, HMR) disposes the previous default first.
|
|
290
|
+
*/
|
|
291
|
+
export function configureBridge(options) {
|
|
292
|
+
defaultBridge?.dispose();
|
|
293
|
+
defaultBridge = new ShenoraBridge(options);
|
|
294
|
+
return defaultBridge;
|
|
295
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only IPC + event-hub interceptor, ported from the primary desktop sibling (NEVER ship it
|
|
3
|
+
* in prod — gate the single call site with `import.meta.env.DEV`).
|
|
4
|
+
*
|
|
5
|
+
* Why: during desktop-app testing the agent drives the UI over CDP, but native dialogs and
|
|
6
|
+
* event-driven flows can't be exercised by clicking. This wraps the bridge's `invoke` (the IPC
|
|
7
|
+
* seam) and the event bus's `emit` (the event hub) to (1) record + console.debug every
|
|
8
|
+
* request/response/event into ring buffers, and (2) expose a window global so a CDP eval can
|
|
9
|
+
* invoke ANY IPC directly and await events:
|
|
10
|
+
*
|
|
11
|
+
* window.__shenora.call('NOTES', 'ADD', { title: 'x' }) // drive an IPC, bypass the UI
|
|
12
|
+
* window.__shenora.waitEvent('NOTES', 'ADDED') // resolves on the next emit
|
|
13
|
+
* window.__shenora.recentIpc(20) / .recentEvents(20) // inspect traffic
|
|
14
|
+
*/
|
|
15
|
+
import { type ShenoraBridge } from './bridge.js';
|
|
16
|
+
import { type ShenoraEventBus } from './eventBus.js';
|
|
17
|
+
/** One recorded IPC call. */
|
|
18
|
+
export interface DevIpcEntry {
|
|
19
|
+
t: number;
|
|
20
|
+
module: string;
|
|
21
|
+
type: string;
|
|
22
|
+
payload?: unknown;
|
|
23
|
+
ms?: number;
|
|
24
|
+
ok?: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
result?: unknown;
|
|
27
|
+
}
|
|
28
|
+
/** One recorded event emit. */
|
|
29
|
+
export interface DevEventEntry {
|
|
30
|
+
t: number;
|
|
31
|
+
module: string;
|
|
32
|
+
type: string;
|
|
33
|
+
payload?: unknown;
|
|
34
|
+
}
|
|
35
|
+
/** Inputs for {@link installDevInterceptor}. */
|
|
36
|
+
export interface DevInterceptorOptions {
|
|
37
|
+
/** The window global to expose. Default `"__shenora"`. */
|
|
38
|
+
globalName?: string;
|
|
39
|
+
/** Ring-buffer capacity per stream. Default 300. */
|
|
40
|
+
ringSize?: number;
|
|
41
|
+
/** The bridge to wrap. Default: the shared default bridge. */
|
|
42
|
+
bridge?: ShenoraBridge;
|
|
43
|
+
/** The event bus to wrap. Default: the shared bus. */
|
|
44
|
+
bus?: ShenoraEventBus;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Install the interceptor (idempotent across HMR / StrictMode double-invoke — keyed on the
|
|
48
|
+
* window global).
|
|
49
|
+
*/
|
|
50
|
+
export declare function installDevInterceptor(options?: DevInterceptorOptions): void;
|