@opengeni/react 0.41.0 → 0.42.1

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/src/index.ts CHANGED
@@ -49,12 +49,41 @@ export {
49
49
  FILE_ONLY_MESSAGE_TEXT,
50
50
  } from "./hooks/use-composer";
51
51
  export type { ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
52
- export { useVoiceInput } from "./hooks/use-voice-input";
52
+ export {
53
+ VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
54
+ VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
55
+ VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
56
+ VOICE_RECORDING_TIMESLICE_MILLISECONDS,
57
+ useVoiceInput,
58
+ } from "./hooks/use-voice-input";
53
59
  export type {
54
60
  UseVoiceInputOptions,
55
61
  UseVoiceInputResult,
56
62
  VoiceInputStatus,
57
63
  } from "./hooks/use-voice-input";
64
+ export {
65
+ IndexedDbVoiceRecordingStore,
66
+ VoiceRecordingChunkConflictError,
67
+ VoiceRecordingChunkSequenceError,
68
+ VoiceRecordingNotFoundError,
69
+ VoiceRecordingOwnedError,
70
+ VoiceRecordingStorageUnavailableError,
71
+ createVoiceRecordingManifest,
72
+ planVoiceRecordingChunkCommit,
73
+ prepareVoiceRecordingChunk,
74
+ } from "./voice-recording-store";
75
+ export type {
76
+ PersistVoiceRecordingChunkInput,
77
+ PersistVoiceRecordingChunkResult,
78
+ VoiceRecordingCaptureState,
79
+ VoiceRecordingChunk,
80
+ VoiceRecordingChunkUploadState,
81
+ VoiceRecordingFinalizationState,
82
+ VoiceRecordingManifest,
83
+ VoiceRecordingStore,
84
+ VoiceRecordingTranscriptionState,
85
+ VoiceRecordingUploadState,
86
+ } from "./voice-recording-store";
58
87
  export { COMPOSER_PAYMENT_REQUIRED_MESSAGE, composerSubmissionErrorMessage } from "./lib/format";
59
88
  export {
60
89
  INITIAL_TRANSCRIPTION_CONTROL_STATE,
@@ -0,0 +1,251 @@
1
+ export const VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
2
+
3
+ const VOICE_RECORDING_OWNER_LOCK_PREFIX = "opengeni.voice-recording-owner:";
4
+ const VOICE_RECORDING_OWNER_CHANNEL = "opengeni.voice-recording-owner.v1";
5
+ const OWNER_LOCK_RELOAD_GRACE_MILLISECONDS = 250;
6
+ const OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS = 5_000;
7
+ const OWNER_BROADCAST_PROBE_MILLISECONDS = 100;
8
+ const OWNER_BROADCAST_RELOAD_ATTEMPTS = 10;
9
+
10
+ export type VoiceRecordingOwnerLease = {
11
+ ownerId: string;
12
+ release: () => void;
13
+ };
14
+
15
+ type UnderlyingOwnerLease = VoiceRecordingOwnerLease;
16
+
17
+ type OwnerProbeMessage = {
18
+ type: "voice-recording-owner.probe";
19
+ ownerId: string;
20
+ instanceId: string;
21
+ };
22
+
23
+ type OwnerOccupiedMessage = {
24
+ type: "voice-recording-owner.occupied";
25
+ ownerId: string;
26
+ targetInstanceId: string;
27
+ };
28
+
29
+ let sharedLeasePromise: Promise<UnderlyingOwnerLease> | null = null;
30
+ let sharedLeaseConsumers = 0;
31
+
32
+ /**
33
+ * Acquire one document-scoped owner identity shared by every voice hook in the
34
+ * current document. The session-stored candidate survives reload, while the
35
+ * held lock/handshake prevents opener-created or duplicated tabs from reusing
36
+ * that identity concurrently.
37
+ */
38
+ export async function acquireDefaultVoiceRecordingOwnerLease(): Promise<VoiceRecordingOwnerLease> {
39
+ sharedLeaseConsumers += 1;
40
+ sharedLeasePromise ??= createUnderlyingOwnerLease();
41
+ let underlying: UnderlyingOwnerLease;
42
+ try {
43
+ underlying = await sharedLeasePromise;
44
+ } catch (error) {
45
+ sharedLeaseConsumers -= 1;
46
+ if (sharedLeaseConsumers === 0) sharedLeasePromise = null;
47
+ throw error;
48
+ }
49
+
50
+ let released = false;
51
+ return {
52
+ ownerId: underlying.ownerId,
53
+ release: () => {
54
+ if (released) return;
55
+ released = true;
56
+ sharedLeaseConsumers = Math.max(0, sharedLeaseConsumers - 1);
57
+ if (sharedLeaseConsumers === 0) {
58
+ sharedLeasePromise = null;
59
+ underlying.release();
60
+ }
61
+ },
62
+ };
63
+ }
64
+
65
+ async function createUnderlyingOwnerLease(): Promise<UnderlyingOwnerLease> {
66
+ const candidate = readSessionOwnerId() ?? crypto.randomUUID();
67
+ const reloadNavigation = isReloadNavigation();
68
+
69
+ if (hasWebLocks()) {
70
+ const retained = await tryAcquireWebLock(
71
+ candidate,
72
+ reloadNavigation
73
+ ? OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS
74
+ : OWNER_LOCK_RELOAD_GRACE_MILLISECONDS,
75
+ );
76
+ if (retained) {
77
+ writeSessionOwnerId(candidate);
78
+ return retained;
79
+ }
80
+
81
+ const rotated = crypto.randomUUID();
82
+ writeSessionOwnerId(rotated);
83
+ const acquired = await tryAcquireWebLock(rotated, OWNER_LOCK_RELOAD_GRACE_MILLISECONDS);
84
+ if (acquired) return acquired;
85
+ }
86
+
87
+ const broadcastLease = await tryAcquireBroadcastLease(
88
+ readSessionOwnerId() ?? candidate,
89
+ reloadNavigation ? OWNER_BROADCAST_RELOAD_ATTEMPTS : 0,
90
+ );
91
+ if (broadcastLease) return broadcastLease;
92
+
93
+ // Without a cross-document coordination primitive, prefer a fresh
94
+ // per-document identity over copied session state. Recovery then waits only
95
+ // for the ordinary stale-owner timeout instead of risking cross-tab access.
96
+ const ownerId = crypto.randomUUID();
97
+ writeSessionOwnerId(ownerId);
98
+ return { ownerId, release: () => undefined };
99
+ }
100
+
101
+ function hasWebLocks(): boolean {
102
+ return (
103
+ typeof navigator !== "undefined" &&
104
+ navigator.locks !== null &&
105
+ navigator.locks !== undefined &&
106
+ typeof navigator.locks.request === "function"
107
+ );
108
+ }
109
+
110
+ async function tryAcquireWebLock(
111
+ ownerId: string,
112
+ waitMilliseconds: number,
113
+ ): Promise<UnderlyingOwnerLease | null> {
114
+ if (!hasWebLocks()) return null;
115
+
116
+ return await new Promise<UnderlyingOwnerLease | null>((resolve) => {
117
+ const controller = new AbortController();
118
+ let settled = false;
119
+ const settle = (lease: UnderlyingOwnerLease | null) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ resolve(lease);
123
+ };
124
+ const timeout = setTimeout(() => controller.abort(), waitMilliseconds);
125
+
126
+ void navigator.locks
127
+ .request(
128
+ `${VOICE_RECORDING_OWNER_LOCK_PREFIX}${ownerId}`,
129
+ { mode: "exclusive", signal: controller.signal },
130
+ async () => {
131
+ clearTimeout(timeout);
132
+ let releaseLock: (() => void) | null = null;
133
+ const held = new Promise<void>((release) => {
134
+ releaseLock = release;
135
+ });
136
+ settle({
137
+ ownerId,
138
+ release: () => releaseLock?.(),
139
+ });
140
+ await held;
141
+ },
142
+ )
143
+ .catch(() => {
144
+ clearTimeout(timeout);
145
+ settle(null);
146
+ });
147
+ });
148
+ }
149
+
150
+ async function tryAcquireBroadcastLease(
151
+ initialOwnerId: string,
152
+ retainCandidateAttempts: number,
153
+ ): Promise<UnderlyingOwnerLease | null> {
154
+ const BroadcastChannelConstructor =
155
+ typeof window !== "undefined" ? window.BroadcastChannel : undefined;
156
+ if (!BroadcastChannelConstructor) return null;
157
+
158
+ let ownerId = initialOwnerId;
159
+ for (let attempt = 0; attempt < retainCandidateAttempts + 3; attempt += 1) {
160
+ const channel = new BroadcastChannelConstructor(VOICE_RECORDING_OWNER_CHANNEL);
161
+ const instanceId = crypto.randomUUID();
162
+ let occupied = false;
163
+ const onMessage = (event: MessageEvent<unknown>) => {
164
+ const message = ownerCoordinationMessage(event.data);
165
+ if (!message || message.ownerId !== ownerId) return;
166
+ if (message.type === "voice-recording-owner.probe") {
167
+ if (message.instanceId === instanceId) return;
168
+ channel.postMessage({
169
+ type: "voice-recording-owner.occupied",
170
+ ownerId,
171
+ targetInstanceId: message.instanceId,
172
+ } satisfies OwnerOccupiedMessage);
173
+ return;
174
+ }
175
+ if (message.targetInstanceId === instanceId) occupied = true;
176
+ };
177
+ channel.addEventListener("message", onMessage);
178
+ channel.postMessage({
179
+ type: "voice-recording-owner.probe",
180
+ ownerId,
181
+ instanceId,
182
+ } satisfies OwnerProbeMessage);
183
+ await delay(OWNER_BROADCAST_PROBE_MILLISECONDS);
184
+ if (!occupied) {
185
+ writeSessionOwnerId(ownerId);
186
+ return {
187
+ ownerId,
188
+ release: () => {
189
+ channel.removeEventListener("message", onMessage);
190
+ channel.close();
191
+ },
192
+ };
193
+ }
194
+ channel.removeEventListener("message", onMessage);
195
+ channel.close();
196
+ if (attempt < retainCandidateAttempts) continue;
197
+ ownerId = crypto.randomUUID();
198
+ writeSessionOwnerId(ownerId);
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function isReloadNavigation(): boolean {
204
+ if (typeof performance === "undefined") return false;
205
+ return performance.getEntriesByType("navigation").some((entry) => {
206
+ return "type" in entry && entry.type === "reload";
207
+ });
208
+ }
209
+
210
+ function ownerCoordinationMessage(value: unknown): OwnerProbeMessage | OwnerOccupiedMessage | null {
211
+ if (!value || typeof value !== "object") return null;
212
+ const candidate = value as Record<string, unknown>;
213
+ if (
214
+ candidate.type === "voice-recording-owner.probe" &&
215
+ typeof candidate.ownerId === "string" &&
216
+ typeof candidate.instanceId === "string"
217
+ ) {
218
+ return candidate as OwnerProbeMessage;
219
+ }
220
+ if (
221
+ candidate.type === "voice-recording-owner.occupied" &&
222
+ typeof candidate.ownerId === "string" &&
223
+ typeof candidate.targetInstanceId === "string"
224
+ ) {
225
+ return candidate as OwnerOccupiedMessage;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function readSessionOwnerId(): string | null {
231
+ try {
232
+ return typeof window === "undefined"
233
+ ? null
234
+ : window.sessionStorage.getItem(VOICE_RECORDING_OWNER_SESSION_KEY);
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ function writeSessionOwnerId(ownerId: string): void {
241
+ try {
242
+ window.sessionStorage.setItem(VOICE_RECORDING_OWNER_SESSION_KEY, ownerId);
243
+ } catch {
244
+ // Private/embedded contexts may deny session storage. The held lock or
245
+ // broadcast lease still keeps the in-memory identity document-scoped.
246
+ }
247
+ }
248
+
249
+ function delay(milliseconds: number): Promise<void> {
250
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
251
+ }