@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
|
@@ -0,0 +1,92 @@
|
|
|
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 { getBridge } from './bridge.js';
|
|
16
|
+
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
17
|
+
/**
|
|
18
|
+
* Install the interceptor (idempotent across HMR / StrictMode double-invoke — keyed on the
|
|
19
|
+
* window global).
|
|
20
|
+
*/
|
|
21
|
+
export function installDevInterceptor(options = {}) {
|
|
22
|
+
if (typeof window === 'undefined')
|
|
23
|
+
return;
|
|
24
|
+
const globalName = options.globalName ?? '__shenora';
|
|
25
|
+
const w = window;
|
|
26
|
+
if (w[globalName])
|
|
27
|
+
return;
|
|
28
|
+
const ringSize = options.ringSize ?? 300;
|
|
29
|
+
const bridge = options.bridge ?? getBridge();
|
|
30
|
+
const bus = options.bus ?? defaultEventBus;
|
|
31
|
+
const ipc = [];
|
|
32
|
+
const events = [];
|
|
33
|
+
const push = (buffer, entry) => {
|
|
34
|
+
buffer.push(entry);
|
|
35
|
+
if (buffer.length > ringSize)
|
|
36
|
+
buffer.shift();
|
|
37
|
+
};
|
|
38
|
+
// --- wrap the IPC send seam ---
|
|
39
|
+
const originalInvoke = bridge.invoke.bind(bridge);
|
|
40
|
+
bridge.invoke = (module, type, invokeOptions = {}) => {
|
|
41
|
+
const start = performance.now();
|
|
42
|
+
const entry = { t: Date.now(), module, type, payload: invokeOptions.payload };
|
|
43
|
+
push(ipc, entry);
|
|
44
|
+
console.debug(`[IPC →] ${module}.${type}`, invokeOptions.payload);
|
|
45
|
+
return originalInvoke(module, type, invokeOptions).then((result) => {
|
|
46
|
+
entry.ms = Math.round(performance.now() - start);
|
|
47
|
+
entry.ok = true;
|
|
48
|
+
entry.result = result;
|
|
49
|
+
console.debug(`[IPC ✓] ${module}.${type} (${entry.ms}ms)`, result);
|
|
50
|
+
return result;
|
|
51
|
+
}, (error) => {
|
|
52
|
+
entry.ms = Math.round(performance.now() - start);
|
|
53
|
+
entry.ok = false;
|
|
54
|
+
entry.error = error?.message;
|
|
55
|
+
console.debug(`[IPC ✗] ${module}.${type} (${entry.ms}ms)`, error?.message);
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
// --- wrap the event hub ---
|
|
60
|
+
const originalEmit = bus.emit.bind(bus);
|
|
61
|
+
bus.emit = (event) => {
|
|
62
|
+
push(events, { t: Date.now(), module: event.module, type: event.type, payload: event.payload });
|
|
63
|
+
console.debug(`[EVT] ${event.module}.${event.type}`, event.payload);
|
|
64
|
+
originalEmit(event);
|
|
65
|
+
};
|
|
66
|
+
w[globalName] = {
|
|
67
|
+
bridge,
|
|
68
|
+
eventBus: bus,
|
|
69
|
+
ipc,
|
|
70
|
+
events,
|
|
71
|
+
/** Drive ANY IPC directly (bypasses native dialogs / UI). Returns the response promise. */
|
|
72
|
+
call: (module, type, payload, scope) => bridge.invoke(module, type, { payload, scope }),
|
|
73
|
+
/** Resolve on the next matching event (or null on timeout) — for CDP awaitPromise verification. */
|
|
74
|
+
waitEvent: (module, type, timeoutMs = 8000) => new Promise((resolve) => {
|
|
75
|
+
const off = bus.subscribe(module, type, (event) => {
|
|
76
|
+
off();
|
|
77
|
+
resolve(event);
|
|
78
|
+
});
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
off();
|
|
81
|
+
resolve(null);
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
}),
|
|
84
|
+
recentIpc: (n = 20) => ipc.slice(-n),
|
|
85
|
+
recentEvents: (n = 20) => events.slice(-n),
|
|
86
|
+
clear: () => {
|
|
87
|
+
ipc.length = 0;
|
|
88
|
+
events.length = 0;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
console.info(`[shenora] dev interceptor installed → window.${globalName} (call(), waitEvent(), recentIpc(), recentEvents())`);
|
|
92
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IpcError } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The rejection type for every failed bridge call — the client mirror of the host's
|
|
4
|
+
* `OperationException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
|
|
5
|
+
* `Exception` suffix). Carries the structured code + parameters so callers translate
|
|
6
|
+
* `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
|
|
7
|
+
* transport) reject through this same shape with the client-reserved codes.
|
|
8
|
+
*/
|
|
9
|
+
export declare class OperationError extends Error {
|
|
10
|
+
/** Error code / i18n key (e.g. `"IMPORT_FAILED"`, `"TIMEOUT"`). */
|
|
11
|
+
readonly code: string;
|
|
12
|
+
/** Values the client interpolates into the translated message. */
|
|
13
|
+
readonly parameters?: Record<string, string>;
|
|
14
|
+
constructor(error: IpcError);
|
|
15
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rejection type for every failed bridge call — the client mirror of the host's
|
|
3
|
+
* `OperationException` (the `Error` suffix is the TS idiom; the C# type keeps the platform's
|
|
4
|
+
* `Exception` suffix). Carries the structured code + parameters so callers translate
|
|
5
|
+
* `errors.{code}` instead of matching message strings. Client-side failures (timeout, missing
|
|
6
|
+
* transport) reject through this same shape with the client-reserved codes.
|
|
7
|
+
*/
|
|
8
|
+
export class OperationError extends Error {
|
|
9
|
+
constructor(error) {
|
|
10
|
+
super(error.message ?? error.code);
|
|
11
|
+
this.name = 'OperationError';
|
|
12
|
+
this.code = error.code;
|
|
13
|
+
this.parameters = error.parameters;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { EventMessage } from './types.js';
|
|
2
|
+
/** Optional narrowing for the {@link ShenoraEventBus} subscribe methods. */
|
|
3
|
+
export interface SubscribeOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Only receive events carrying this app-defined scope. Omit to receive EVERY scope.
|
|
6
|
+
*
|
|
7
|
+
* The semantics mirror the host's `EventBus` exactly, and both halves matter: an unscoped
|
|
8
|
+
* subscription sees every scope, AND a scope-less (global) event still reaches scoped subscribers —
|
|
9
|
+
* so an app-wide announcement is not swallowed by a per-scope listener.
|
|
10
|
+
*/
|
|
11
|
+
scope?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The client-side event hub, ported from the primary desktop sibling: host notifications are
|
|
15
|
+
* unbundled into it by the bridge, and app code (or the dev interceptor) can emit locally.
|
|
16
|
+
* Event enums/maps are app schema, so apps layer their own typed wrappers on top (headless per D13).
|
|
17
|
+
* A throwing handler is isolated: it never breaks the other subscribers or the emitter.
|
|
18
|
+
*
|
|
19
|
+
* Three subscription breadths, mirroring the host's `Shenora.Core.IEventBus`: an exact
|
|
20
|
+
* `(module, type)` pair, a whole {@link subscribeToModule | module}, or
|
|
21
|
+
* {@link subscribeToAll | everything}. The broad two were added in P6.4 — the host had shipped them
|
|
22
|
+
* from the start (`WebViewIpcBridge` itself consumes `SubscribeToAll`), so the client was the
|
|
23
|
+
* asymmetric half of one concept and an observer that cannot enumerate the event vocabulary up front
|
|
24
|
+
* had no supported expression at all.
|
|
25
|
+
*/
|
|
26
|
+
export declare class ShenoraEventBus {
|
|
27
|
+
/** Exact `(module, type)` subscriptions, keyed by {@link eventKey}. */
|
|
28
|
+
private exact;
|
|
29
|
+
/** Whole-module subscriptions, keyed by module. */
|
|
30
|
+
private byModule;
|
|
31
|
+
/** Catch-all subscriptions. */
|
|
32
|
+
private all;
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to one (module, type), optionally narrowed to a scope; returns the cleanup function
|
|
35
|
+
* (React-effect friendly).
|
|
36
|
+
*/
|
|
37
|
+
subscribe<TPayload = unknown>(module: string, type: string, handler: (event: EventMessage<TPayload>) => void, options?: SubscribeOptions): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* Subscribe to EVERY event from one module, whatever its type; returns the cleanup function.
|
|
40
|
+
*
|
|
41
|
+
* For a feature whose event vocabulary is open — plug-in-contributed types, a module that grows
|
|
42
|
+
* types over time — where enumerating pairs would mean editing the subscriber every time the host
|
|
43
|
+
* gains an event.
|
|
44
|
+
*/
|
|
45
|
+
subscribeToModule<TPayload = unknown>(module: string, handler: (event: EventMessage<TPayload>) => void, options?: SubscribeOptions): () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to EVERY event on the bus; returns the cleanup function.
|
|
48
|
+
*
|
|
49
|
+
* The breadth is the point, so use it for cross-cutting observers — a diagnostics overlay, a
|
|
50
|
+
* telemetry tap, a bridge that folds the whole stream into another state library, or an adoption
|
|
51
|
+
* shim keeping a legacy "every host message" handler alive while individual features migrate onto
|
|
52
|
+
* exact pairs. It is NOT the way to consume one feature's events: prefer {@link subscribe}, which
|
|
53
|
+
* says what it listens for and does not wake for unrelated traffic.
|
|
54
|
+
*/
|
|
55
|
+
subscribeToAll<TPayload = unknown>(handler: (event: EventMessage<TPayload>) => void, options?: SubscribeOptions): () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Emit to every matching subscriber (see {@link SubscribeOptions.scope} for the scope rule).
|
|
58
|
+
*
|
|
59
|
+
* Delivery order is stable: exact pair, then whole-module, then catch-all — narrowest first, so a
|
|
60
|
+
* broad observer never runs ahead of the feature code it is observing.
|
|
61
|
+
*/
|
|
62
|
+
emit(event: EventMessage): void;
|
|
63
|
+
/** Remove every subscription (tests). */
|
|
64
|
+
clear(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Subscription count (diagnostics). With no arguments: every subscription of any breadth. With a
|
|
67
|
+
* `(module, type)`: every subscription that WOULD receive that pair — exact, whole-module and
|
|
68
|
+
* catch-all — because "how many listeners does this event have?" is the question a diagnostic is
|
|
69
|
+
* actually asking. Scope is not applied; a count is not a delivery.
|
|
70
|
+
*/
|
|
71
|
+
getSubscriptionCount(module?: string, type?: string): number;
|
|
72
|
+
}
|
|
73
|
+
/** The shared event bus the default bridge unbundles into. */
|
|
74
|
+
export declare const eventBus: ShenoraEventBus;
|
package/dist/eventBus.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side event hub, ported from the primary desktop sibling: host notifications are
|
|
3
|
+
* unbundled into it by the bridge, and app code (or the dev interceptor) can emit locally.
|
|
4
|
+
* Event enums/maps are app schema, so apps layer their own typed wrappers on top (headless per D13).
|
|
5
|
+
* A throwing handler is isolated: it never breaks the other subscribers or the emitter.
|
|
6
|
+
*
|
|
7
|
+
* Three subscription breadths, mirroring the host's `Shenora.Core.IEventBus`: an exact
|
|
8
|
+
* `(module, type)` pair, a whole {@link subscribeToModule | module}, or
|
|
9
|
+
* {@link subscribeToAll | everything}. The broad two were added in P6.4 — the host had shipped them
|
|
10
|
+
* from the start (`WebViewIpcBridge` itself consumes `SubscribeToAll`), so the client was the
|
|
11
|
+
* asymmetric half of one concept and an observer that cannot enumerate the event vocabulary up front
|
|
12
|
+
* had no supported expression at all.
|
|
13
|
+
*/
|
|
14
|
+
export class ShenoraEventBus {
|
|
15
|
+
constructor() {
|
|
16
|
+
/** Exact `(module, type)` subscriptions, keyed by {@link eventKey}. */
|
|
17
|
+
this.exact = new Map();
|
|
18
|
+
/** Whole-module subscriptions, keyed by module. */
|
|
19
|
+
this.byModule = new Map();
|
|
20
|
+
/** Catch-all subscriptions. */
|
|
21
|
+
this.all = new Set();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Subscribe to one (module, type), optionally narrowed to a scope; returns the cleanup function
|
|
25
|
+
* (React-effect friendly).
|
|
26
|
+
*/
|
|
27
|
+
subscribe(module, type, handler, options = {}) {
|
|
28
|
+
return addTo(this.exact, eventKey(module, type), newSubscription(handler, options));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe to EVERY event from one module, whatever its type; returns the cleanup function.
|
|
32
|
+
*
|
|
33
|
+
* For a feature whose event vocabulary is open — plug-in-contributed types, a module that grows
|
|
34
|
+
* types over time — where enumerating pairs would mean editing the subscriber every time the host
|
|
35
|
+
* gains an event.
|
|
36
|
+
*/
|
|
37
|
+
subscribeToModule(module, handler, options = {}) {
|
|
38
|
+
return addTo(this.byModule, module, newSubscription(handler, options));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to EVERY event on the bus; returns the cleanup function.
|
|
42
|
+
*
|
|
43
|
+
* The breadth is the point, so use it for cross-cutting observers — a diagnostics overlay, a
|
|
44
|
+
* telemetry tap, a bridge that folds the whole stream into another state library, or an adoption
|
|
45
|
+
* shim keeping a legacy "every host message" handler alive while individual features migrate onto
|
|
46
|
+
* exact pairs. It is NOT the way to consume one feature's events: prefer {@link subscribe}, which
|
|
47
|
+
* says what it listens for and does not wake for unrelated traffic.
|
|
48
|
+
*/
|
|
49
|
+
subscribeToAll(handler, options = {}) {
|
|
50
|
+
const subscription = newSubscription(handler, options);
|
|
51
|
+
this.all.add(subscription);
|
|
52
|
+
return () => { this.all.delete(subscription); };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Emit to every matching subscriber (see {@link SubscribeOptions.scope} for the scope rule).
|
|
56
|
+
*
|
|
57
|
+
* Delivery order is stable: exact pair, then whole-module, then catch-all — narrowest first, so a
|
|
58
|
+
* broad observer never runs ahead of the feature code it is observing.
|
|
59
|
+
*/
|
|
60
|
+
emit(event) {
|
|
61
|
+
const exact = this.exact.get(eventKey(event.module, event.type));
|
|
62
|
+
const byModule = this.byModule.get(event.module);
|
|
63
|
+
if (!exact?.size && !byModule?.size && this.all.size === 0)
|
|
64
|
+
return;
|
|
65
|
+
// Snapshot ALL THREE breadths before invoking any handler, not one at a time. A handler may
|
|
66
|
+
// subscribe or unsubscribe during delivery, and one event must reach exactly the subscribers
|
|
67
|
+
// that existed when it was emitted. Reading the broad collections lazily — after the exact
|
|
68
|
+
// handlers had already run — would let a handler that subscribes broadly while handling receive
|
|
69
|
+
// the very event it is handling. Copying per-set was enough while there was only one set.
|
|
70
|
+
for (const subscription of [...(exact ?? []), ...(byModule ?? []), ...this.all]) {
|
|
71
|
+
if (!scopeMatches(subscription.scope, event.scope))
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
subscription.handler(event);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// One subscriber's failure must not break the others.
|
|
78
|
+
console.error(`[shenora] event handler failed for ${event.module}/${event.type}:`, error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Remove every subscription (tests). */
|
|
83
|
+
clear() {
|
|
84
|
+
this.exact.clear();
|
|
85
|
+
this.byModule.clear();
|
|
86
|
+
this.all.clear();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Subscription count (diagnostics). With no arguments: every subscription of any breadth. With a
|
|
90
|
+
* `(module, type)`: every subscription that WOULD receive that pair — exact, whole-module and
|
|
91
|
+
* catch-all — because "how many listeners does this event have?" is the question a diagnostic is
|
|
92
|
+
* actually asking. Scope is not applied; a count is not a delivery.
|
|
93
|
+
*/
|
|
94
|
+
getSubscriptionCount(module, type) {
|
|
95
|
+
if (module && type) {
|
|
96
|
+
return (this.exact.get(eventKey(module, type))?.size ?? 0)
|
|
97
|
+
+ (this.byModule.get(module)?.size ?? 0)
|
|
98
|
+
+ this.all.size;
|
|
99
|
+
}
|
|
100
|
+
let total = this.all.size;
|
|
101
|
+
for (const set of this.exact.values())
|
|
102
|
+
total += set.size;
|
|
103
|
+
for (const set of this.byModule.values())
|
|
104
|
+
total += set.size;
|
|
105
|
+
return total;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** The one place a handler is widened to the stored shape, so the three breadths cannot drift. */
|
|
109
|
+
function newSubscription(handler, options) {
|
|
110
|
+
return { handler: handler, scope: options.scope };
|
|
111
|
+
}
|
|
112
|
+
/** Shared add-and-prune for the two keyed collections (an empty key is removed, as it always was). */
|
|
113
|
+
function addTo(collection, key, subscription) {
|
|
114
|
+
let set = collection.get(key);
|
|
115
|
+
if (!set) {
|
|
116
|
+
set = new Set();
|
|
117
|
+
collection.set(key, set);
|
|
118
|
+
}
|
|
119
|
+
set.add(subscription);
|
|
120
|
+
return () => {
|
|
121
|
+
const current = collection.get(key);
|
|
122
|
+
if (!current)
|
|
123
|
+
return;
|
|
124
|
+
current.delete(subscription);
|
|
125
|
+
if (current.size === 0)
|
|
126
|
+
collection.delete(key);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `'\0'`-joined, NOT `.`-joined (P5.5 H6).
|
|
131
|
+
*
|
|
132
|
+
* Module and type are both arbitrary app-defined strings, so a `.` separator makes
|
|
133
|
+
* `("APP", "TASK.DONE")` and `("APP.TASK", "DONE")` the same key — one app's events silently delivered
|
|
134
|
+
* to another's subscribers. The host's `EventBus` fixed exactly this and documented it; the client kept
|
|
135
|
+
* the colliding form, so the two halves of one contract disagreed. `'\0'` cannot appear in a JS string
|
|
136
|
+
* literal a developer types by accident, which is what makes it safe as a separator.
|
|
137
|
+
*
|
|
138
|
+
* The broad subscriptions deliberately do NOT reuse this with a `"*"` sentinel the way the host's
|
|
139
|
+
* pattern matcher does: a module or type legitimately named `*` would then silently become a
|
|
140
|
+
* catch-all. Separate collections cannot collide with an app string at all — same lesson as above,
|
|
141
|
+
* applied before it could be earned a second time.
|
|
142
|
+
*/
|
|
143
|
+
function eventKey(module, type) {
|
|
144
|
+
return `${module}\0${type}`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The host's rule, restated: no subscriber scope = every scope; no event scope = a global event that
|
|
148
|
+
* reaches scoped subscribers too. Otherwise they must be equal.
|
|
149
|
+
*/
|
|
150
|
+
function scopeMatches(subscriptionScope, eventScope) {
|
|
151
|
+
if (!subscriptionScope)
|
|
152
|
+
return true;
|
|
153
|
+
if (!eventScope)
|
|
154
|
+
return true;
|
|
155
|
+
return subscriptionScope === eventScope;
|
|
156
|
+
}
|
|
157
|
+
/** The shared event bus the default bridge unbundles into. */
|
|
158
|
+
export const eventBus = new ShenoraEventBus();
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ShenoraBridge } from './bridge.js';
|
|
2
|
+
import { type ShenoraEventBus } from './eventBus.js';
|
|
3
|
+
import type { EventMessage } from './types.js';
|
|
4
|
+
/** Host access for components: the (default) bridge and whether a host transport exists. */
|
|
5
|
+
export declare function useShenora(): {
|
|
6
|
+
isAvailable: boolean;
|
|
7
|
+
bridge: ShenoraBridge;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe to one (module, type) event for the component's lifetime, ported from the primary
|
|
11
|
+
* desktop sibling. The handler receives the unwrapped payload (plus the full event). DEVIATION
|
|
12
|
+
* from the source: instead of a deps array re-subscribing on change, the latest handler is kept
|
|
13
|
+
* in a ref — no re-subscribe churn, no stale-closure trap.
|
|
14
|
+
*
|
|
15
|
+
* Pass `scope` for a scoped app: the wire carries a scope and the host keys on it, but this hook had
|
|
16
|
+
* no way to express one, so a component in profile A also woke for profile B's events with no filter
|
|
17
|
+
* available (P5.5 H6). Omitting it still means "every scope", and a global (scope-less) event still
|
|
18
|
+
* reaches a scoped subscriber — the host's rule, mirrored.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useShenoraEvent<TPayload = unknown>(module: string, type: string, handler: (payload: TPayload, event: EventMessage<TPayload>) => void, options?: {
|
|
21
|
+
bus?: ShenoraEventBus;
|
|
22
|
+
scope?: string;
|
|
23
|
+
}): void;
|
|
24
|
+
/** Result of {@link useShenoraQuery}. */
|
|
25
|
+
export interface ShenoraQueryResult<TData> {
|
|
26
|
+
data: TData | undefined;
|
|
27
|
+
error: Error | undefined;
|
|
28
|
+
/** True while a fetch is in flight. */
|
|
29
|
+
loading: boolean;
|
|
30
|
+
/** Re-run the query. */
|
|
31
|
+
refetch: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Fetch-on-mount over the bridge: `invoke` + `{data, error, loading, refetch}`. Deliberately
|
|
35
|
+
* minimal — no caching, no dedup, no background refresh (headless, D13): apps with data-layer
|
|
36
|
+
* needs bring their own query library and call `bridge.invoke` from it. The payload participates
|
|
37
|
+
* in the effect key BY VALUE (JSON), so inline object literals don't refetch every render.
|
|
38
|
+
*/
|
|
39
|
+
export declare function useShenoraQuery<TData = unknown, TPayload = unknown>(module: string, type: string, options?: {
|
|
40
|
+
payload?: TPayload;
|
|
41
|
+
scope?: string;
|
|
42
|
+
/** False = don't fetch (yet). Default true. */
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
bridge?: ShenoraBridge;
|
|
45
|
+
}): ShenoraQueryResult<TData>;
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { getBridge } from './bridge.js';
|
|
3
|
+
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
|
+
/** Host access for components: the (default) bridge and whether a host transport exists. */
|
|
5
|
+
export function useShenora() {
|
|
6
|
+
const bridge = getBridge();
|
|
7
|
+
return { isAvailable: bridge.isAvailable, bridge };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe to one (module, type) event for the component's lifetime, ported from the primary
|
|
11
|
+
* desktop sibling. The handler receives the unwrapped payload (plus the full event). DEVIATION
|
|
12
|
+
* from the source: instead of a deps array re-subscribing on change, the latest handler is kept
|
|
13
|
+
* in a ref — no re-subscribe churn, no stale-closure trap.
|
|
14
|
+
*
|
|
15
|
+
* Pass `scope` for a scoped app: the wire carries a scope and the host keys on it, but this hook had
|
|
16
|
+
* no way to express one, so a component in profile A also woke for profile B's events with no filter
|
|
17
|
+
* available (P5.5 H6). Omitting it still means "every scope", and a global (scope-less) event still
|
|
18
|
+
* reaches a scoped subscriber — the host's rule, mirrored.
|
|
19
|
+
*/
|
|
20
|
+
export function useShenoraEvent(module, type, handler, options = {}) {
|
|
21
|
+
const handlerRef = useRef(handler);
|
|
22
|
+
handlerRef.current = handler;
|
|
23
|
+
const bus = options.bus ?? defaultEventBus;
|
|
24
|
+
const scope = options.scope;
|
|
25
|
+
useEffect(() => bus.subscribe(module, type, (event) => handlerRef.current(event.payload, event), { scope }), [module, type, bus, scope]);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Fetch-on-mount over the bridge: `invoke` + `{data, error, loading, refetch}`. Deliberately
|
|
29
|
+
* minimal — no caching, no dedup, no background refresh (headless, D13): apps with data-layer
|
|
30
|
+
* needs bring their own query library and call `bridge.invoke` from it. The payload participates
|
|
31
|
+
* in the effect key BY VALUE (JSON), so inline object literals don't refetch every render.
|
|
32
|
+
*/
|
|
33
|
+
export function useShenoraQuery(module, type, options = {}) {
|
|
34
|
+
const { payload, scope, enabled = true } = options;
|
|
35
|
+
const bridge = options.bridge ?? getBridge();
|
|
36
|
+
const [state, setState] = useState({
|
|
37
|
+
data: undefined,
|
|
38
|
+
error: undefined,
|
|
39
|
+
loading: enabled,
|
|
40
|
+
});
|
|
41
|
+
const [fetchToken, setFetchToken] = useState(0);
|
|
42
|
+
const payloadKey = payload === undefined ? '' : JSON.stringify(payload);
|
|
43
|
+
const payloadRef = useRef(payload);
|
|
44
|
+
payloadRef.current = payload;
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!enabled) {
|
|
47
|
+
// A fetch in flight when enabled flipped false was marked stale by the cleanup — without
|
|
48
|
+
// this, `loading` would stay true forever (a spinner that never stops).
|
|
49
|
+
setState((previous) => (previous.loading ? { ...previous, loading: false } : previous));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let stale = false;
|
|
53
|
+
setState((previous) => ({ ...previous, loading: true }));
|
|
54
|
+
bridge
|
|
55
|
+
.invoke(module, type, { payload: payloadRef.current, scope })
|
|
56
|
+
.then((data) => { if (!stale)
|
|
57
|
+
setState({ data, error: undefined, loading: false }); },
|
|
58
|
+
// KEEP the previous data alongside the error (P5.5 H2). This used to set `data: undefined`, so
|
|
59
|
+
// a failed REFETCH — a transient host hiccup, one timed-out call — blanked data the UI was
|
|
60
|
+
// already showing correctly, turning a recoverable error into an empty screen. The caller has
|
|
61
|
+
// both fields and can decide: render stale data with an error banner, or hide it. Blanking it
|
|
62
|
+
// for them removes that choice. (A first fetch has no previous data, so it is unaffected.)
|
|
63
|
+
(error) => { if (!stale)
|
|
64
|
+
setState((previous) => ({ data: previous.data, error, loading: false })); });
|
|
65
|
+
return () => { stale = true; };
|
|
66
|
+
}, [module, type, scope, enabled, bridge, payloadKey, fetchToken]);
|
|
67
|
+
const refetch = useCallback(() => setFetchToken((token) => token + 1), []);
|
|
68
|
+
return { ...state, refetch };
|
|
69
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, } from './types.js';
|
|
2
|
+
export { OperationError } from './errors.js';
|
|
3
|
+
export { isShenoraAvailable, createWebView2Transport, type ShenoraTransport } from './transport.js';
|
|
4
|
+
export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
5
|
+
export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
|
|
6
|
+
export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
|
|
7
|
+
export { BaseModuleService } from './moduleService.js';
|
|
8
|
+
export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
|
|
9
|
+
export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
|
|
10
|
+
export { useShenora, useShenoraEvent, useShenoraQuery, type ShenoraQueryResult } from './hooks.js';
|
|
11
|
+
export { installDevInterceptor, type DevInterceptorOptions, type DevIpcEntry, type DevEventEntry, } from './devInterceptor.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @shenora/react — the client side of the Shenora desktop body: correlated invoke over a
|
|
2
|
+
// pluggable transport, the event hub host notifications unbundle into, typed module services,
|
|
3
|
+
// React hooks, and the dev interceptor for CDP-driven testing. Headless by design (D13): no UI
|
|
4
|
+
// components, no design-system dependency — apps bring their own.
|
|
5
|
+
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, } from './types.js';
|
|
6
|
+
export { OperationError } from './errors.js';
|
|
7
|
+
export { isShenoraAvailable, createWebView2Transport } from './transport.js';
|
|
8
|
+
export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
9
|
+
export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
|
|
10
|
+
export { createShenoraStore, } from './store.js';
|
|
11
|
+
export { BaseModuleService } from './moduleService.js';
|
|
12
|
+
export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
|
|
13
|
+
export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
|
|
14
|
+
export { useShenora, useShenoraEvent, useShenoraQuery } from './hooks.js';
|
|
15
|
+
export { installDevInterceptor, } from './devInterceptor.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
|
|
3
|
+
* surface, and it must not become so by accident.
|
|
4
|
+
*
|
|
5
|
+
* These lived as per-file copies until a second consumer appeared for each (P5.5 H2): the debounce
|
|
6
|
+
* helper was private to `useDropZone` and `useWindowMaximized` needed the same thing, and the
|
|
7
|
+
* `randomUUID`-with-fallback pair had drifted into two spellings. H4.5 deliberately left both alone
|
|
8
|
+
* at the time, on the grounds that the package had no shared-internals home and inventing one for a
|
|
9
|
+
* single consumer is speculation — this file exists now because the need is real.
|
|
10
|
+
*/
|
|
11
|
+
/** A debounced void callback with a `cancel` for effect teardown. */
|
|
12
|
+
export interface Debounced {
|
|
13
|
+
(): void;
|
|
14
|
+
cancel(): void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Trailing-edge debounce: the callback runs `ms` after the LAST call. `cancel` must be called from a
|
|
18
|
+
* React effect's cleanup, or a pending timer fires against an unmounted component.
|
|
19
|
+
*/
|
|
20
|
+
export declare function debounce(fn: () => void, ms: number): Debounced;
|
|
21
|
+
/**
|
|
22
|
+
* A unique id, optionally prefixed. Correlation ids and zone ids only need uniqueness, not entropy,
|
|
23
|
+
* so the non-`crypto` fallback (ancient or non-secure-context environments) is fine.
|
|
24
|
+
*/
|
|
25
|
+
export declare function randomId(prefix?: string): string;
|
package/dist/internal.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
|
|
3
|
+
* surface, and it must not become so by accident.
|
|
4
|
+
*
|
|
5
|
+
* These lived as per-file copies until a second consumer appeared for each (P5.5 H2): the debounce
|
|
6
|
+
* helper was private to `useDropZone` and `useWindowMaximized` needed the same thing, and the
|
|
7
|
+
* `randomUUID`-with-fallback pair had drifted into two spellings. H4.5 deliberately left both alone
|
|
8
|
+
* at the time, on the grounds that the package had no shared-internals home and inventing one for a
|
|
9
|
+
* single consumer is speculation — this file exists now because the need is real.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Trailing-edge debounce: the callback runs `ms` after the LAST call. `cancel` must be called from a
|
|
13
|
+
* React effect's cleanup, or a pending timer fires against an unmounted component.
|
|
14
|
+
*/
|
|
15
|
+
export function debounce(fn, ms) {
|
|
16
|
+
let timer;
|
|
17
|
+
const wrapped = (() => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
timer = setTimeout(fn, ms);
|
|
20
|
+
});
|
|
21
|
+
wrapped.cancel = () => clearTimeout(timer);
|
|
22
|
+
return wrapped;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A unique id, optionally prefixed. Correlation ids and zone ids only need uniqueness, not entropy,
|
|
26
|
+
* so the non-`crypto` fallback (ancient or non-secure-context environments) is fine.
|
|
27
|
+
*/
|
|
28
|
+
export function randomId(prefix) {
|
|
29
|
+
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
30
|
+
? crypto.randomUUID()
|
|
31
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
32
|
+
return prefix === undefined ? id : `${prefix}${id}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type ShenoraBridge } from './bridge.js';
|
|
2
|
+
/**
|
|
3
|
+
* Base class for typed module services, ported from the primary desktop sibling: each backend
|
|
4
|
+
* module gets one service subclass that binds the module name once and exposes app-typed
|
|
5
|
+
* methods over {@link send}. Bind `TRequests` to the module's request map
|
|
6
|
+
* (`{ [type]: payloadType }`) for compile-time payload checking:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
10
|
+
* class NoteService extends BaseModuleService<NoteRequests> {
|
|
11
|
+
* constructor() { super('NOTES'); }
|
|
12
|
+
* getAll() { return this.send<Note[]>('GET_ALL'); }
|
|
13
|
+
* add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
|
|
18
|
+
* around the same call — the response generic already expresses them, so they're gone.
|
|
19
|
+
*
|
|
20
|
+
* `TRequests extends object`, NOT `extends Record<string, unknown>` (P5.5 H6). The stricter bound was
|
|
21
|
+
* unsatisfiable by a plain `interface` — interfaces get no implicit index signature — so the example
|
|
22
|
+
* above and the README's snippet both failed with TS2344, on the first line an adopter copies. And
|
|
23
|
+
* satisfying it the way the kit's own `windowCommands.ts` did (`interface X extends Record<string,
|
|
24
|
+
* unknown>`) widened `keyof TRequests & string` back to `string`, so a mistyped request type compiled
|
|
25
|
+
* and every payload collapsed to `unknown` — the flagship typed-service feature checking nothing at all.
|
|
26
|
+
*/
|
|
27
|
+
export declare abstract class BaseModuleService<TRequests extends object = Record<string, unknown>> {
|
|
28
|
+
/** The backend module this service fronts (e.g. `"NOTES"`). */
|
|
29
|
+
protected readonly module: string;
|
|
30
|
+
/**
|
|
31
|
+
* The bridge to speak over. Omit to use the shared default bridge, resolved PER CALL — see
|
|
32
|
+
* {@link bridge}.
|
|
33
|
+
*/
|
|
34
|
+
private readonly explicitBridge?;
|
|
35
|
+
protected constructor(
|
|
36
|
+
/** The backend module this service fronts (e.g. `"NOTES"`). */
|
|
37
|
+
module: string,
|
|
38
|
+
/**
|
|
39
|
+
* The bridge to speak over. Omit to use the shared default bridge, resolved PER CALL — see
|
|
40
|
+
* {@link bridge}.
|
|
41
|
+
*/
|
|
42
|
+
explicitBridge?: ShenoraBridge | undefined);
|
|
43
|
+
/**
|
|
44
|
+
* The bridge this service speaks over — resolved on every access, never captured.
|
|
45
|
+
*
|
|
46
|
+
* This used to be a constructor default (`bridge: ShenoraBridge = getBridge()`), which is evaluated
|
|
47
|
+
* at CONSTRUCTION: a service built before `configureBridge()` captured the old default, and
|
|
48
|
+
* `configureBridge` DISPOSES the bridge it replaces — so every later call from that service
|
|
49
|
+
* rejected with "Bridge disposed" for the rest of the session, with nothing to suggest why (P5.5
|
|
50
|
+
* H2). Module services are commonly module-level singletons, so constructing one before the app's
|
|
51
|
+
* startup configuration ran is the normal case, not an edge case. `useDropZone` already resolved
|
|
52
|
+
* lazily for exactly this reason; this matches it.
|
|
53
|
+
*/
|
|
54
|
+
protected get bridge(): ShenoraBridge;
|
|
55
|
+
/** Send one typed request to this module and await the typed response data. */
|
|
56
|
+
protected send<TResponse = unknown, TType extends keyof TRequests & string = keyof TRequests & string>(type: TType, options?: {
|
|
57
|
+
payload?: TRequests[TType];
|
|
58
|
+
scope?: string;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}): Promise<TResponse>;
|
|
61
|
+
}
|