@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.
@@ -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
+ }
@@ -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;
@@ -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();
@@ -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
+ }
@@ -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;
@@ -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
+ }