@livedesk/hub 0.1.41 → 0.1.43

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.
@@ -1,297 +1,297 @@
1
- function finitePositive(value, fallback) {
2
- const number = Number(value);
3
- return Number.isFinite(number) && number > 0 ? number : fallback;
4
- }
5
-
6
- function clamp(value, minimum, maximum) {
7
- return Math.max(minimum, Math.min(maximum, value));
8
- }
9
-
10
- /**
11
- * Owns the Hub-to-browser retry that follows an exact native capture stop.
12
- *
13
- * Contract:
14
- * - one owner per WebSocket/device intent generation;
15
- * - the native stop promise is the event source (no polling loop);
16
- * - retries and total elapsed time are both bounded;
17
- * - supersede, close, success, and terminal failure synchronously release every timer.
18
- */
19
- export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
20
- const now = typeof options.now === 'function' ? options.now : Date.now;
21
- const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
22
- const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
23
- const maxAttempts = Math.max(1, Math.floor(finitePositive(options.maxAttempts, 3)));
24
- const minimumRetryAfterMs = Math.max(1, finitePositive(options.minimumRetryAfterMs, 25));
25
- const maximumRetryAfterMs = Math.max(
26
- minimumRetryAfterMs,
27
- finitePositive(options.maximumRetryAfterMs, 250)
28
- );
29
- const minimumTotalMs = Math.max(1, finitePositive(options.minimumTotalMs, 2_000));
30
- const maximumTotalMs = Math.max(minimumTotalMs, finitePositive(options.maximumTotalMs, 30_000));
31
- const completionMarginMs = Math.max(0, Number(options.completionMarginMs ?? 2_000) || 0);
32
- const clientStates = new WeakMap();
33
- const activeOwners = new Set();
34
-
35
- function stateFor(client) {
36
- if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
37
- throw new TypeError('A capture transition retry requires an exact client owner.');
38
- }
39
- let state = clientStates.get(client);
40
- if (!state) {
41
- state = {
42
- intentGenerations: new Map(),
43
- owners: new Map()
44
- };
45
- clientStates.set(client, state);
46
- }
47
- return state;
48
- }
49
-
50
- function ownerIsCurrent(owner) {
51
- if (!owner || owner.cancelled) {
52
- return false;
53
- }
54
- const state = clientStates.get(owner.client);
55
- return !!state
56
- && state.owners.get(owner.deviceId) === owner
57
- && state.intentGenerations.get(owner.deviceId) === owner.intentGeneration;
58
- }
59
-
60
- function releaseOwner(owner, reason = 'completed') {
61
- if (!owner || owner.cancelled) {
62
- return false;
63
- }
64
- owner.cancelled = true;
65
- owner.cancelReason = reason;
66
- owner.waitToken += 1;
67
- if (owner.timer) {
68
- clearTimer(owner.timer);
69
- owner.timer = null;
70
- }
71
- const state = clientStates.get(owner.client);
72
- if (state?.owners.get(owner.deviceId) === owner) {
73
- state.owners.delete(owner.deviceId);
74
- }
75
- activeOwners.delete(owner);
76
- return true;
77
- }
78
-
79
- function failOwner(owner, error, details = {}) {
80
- if (!ownerIsCurrent(owner)) {
81
- return false;
82
- }
83
- const onTerminal = owner.onTerminal;
84
- const terminal = {
85
- deviceId: owner.deviceId,
86
- intentGeneration: owner.intentGeneration,
87
- attemptCount: owner.attemptCount,
88
- deadlineAtEpochMs: owner.deadlineAtEpochMs,
89
- error,
90
- ...details
91
- };
92
- releaseOwner(owner, error);
93
- try {
94
- onTerminal?.(terminal);
95
- } catch {
96
- // The retry owner is already terminal. UI notification failure cannot
97
- // resurrect it or retain a timer.
98
- }
99
- return true;
100
- }
101
-
102
- function beginIntent(client, deviceId) {
103
- const normalizedDeviceId = String(deviceId || '').trim();
104
- if (!normalizedDeviceId) {
105
- throw new TypeError('A capture transition retry requires a device id.');
106
- }
107
- const state = stateFor(client);
108
- releaseOwner(state.owners.get(normalizedDeviceId), 'superseded');
109
- const current = Number(state.intentGenerations.get(normalizedDeviceId) || 0);
110
- const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
111
- state.intentGenerations.set(normalizedDeviceId, next);
112
- return next;
113
- }
114
-
115
- function complete(client, deviceId, intentGeneration, reason = 'completed') {
116
- const normalizedDeviceId = String(deviceId || '').trim();
117
- const state = clientStates.get(client);
118
- const owner = state?.owners.get(normalizedDeviceId);
119
- if (!owner
120
- || (Number.isSafeInteger(Number(intentGeneration))
121
- && Number(intentGeneration) > 0
122
- && owner.intentGeneration !== Number(intentGeneration))) {
123
- return false;
124
- }
125
- return releaseOwner(owner, reason);
126
- }
127
-
128
- function cancelClient(client, reason = 'client-closed') {
129
- const state = clientStates.get(client);
130
- if (!state) {
131
- return 0;
132
- }
133
- let cancelled = 0;
134
- for (const owner of [...state.owners.values()]) {
135
- if (releaseOwner(owner, reason)) {
136
- cancelled += 1;
137
- }
138
- }
139
- state.intentGenerations.clear();
140
- clientStates.delete(client);
141
- return cancelled;
142
- }
143
-
144
- function schedule(client, request = {}) {
145
- const deviceId = String(request.deviceId || '').trim();
146
- const intentGeneration = Number(request.intentGeneration || 0);
147
- const result = request.result && typeof request.result === 'object'
148
- ? request.result
149
- : {};
150
- const state = stateFor(client);
151
- if (!deviceId
152
- || !Number.isSafeInteger(intentGeneration)
153
- || intentGeneration <= 0
154
- || state.intentGenerations.get(deviceId) !== intentGeneration
155
- || result.error !== 'CAPTURE_TRANSITION_IN_PROGRESS') {
156
- return false;
157
- }
158
-
159
- let owner = state.owners.get(deviceId);
160
- if (owner && owner.intentGeneration !== intentGeneration) {
161
- releaseOwner(owner, 'superseded');
162
- owner = null;
163
- }
164
- const transitionTimeoutMs = clamp(
165
- finitePositive(result.transitionTimeoutMs, 11_000),
166
- minimumRetryAfterMs,
167
- maximumTotalMs
168
- );
169
- if (!owner) {
170
- const totalWindowMs = clamp(
171
- transitionTimeoutMs + completionMarginMs,
172
- minimumTotalMs,
173
- maximumTotalMs
174
- );
175
- owner = {
176
- client,
177
- deviceId,
178
- intentGeneration,
179
- attemptCount: 0,
180
- deadlineAtEpochMs: now() + totalWindowMs,
181
- timer: null,
182
- phase: 'created',
183
- cancelled: false,
184
- cancelReason: '',
185
- waitToken: 0,
186
- retry: request.retry,
187
- onTerminal: request.onTerminal,
188
- transitionTimeoutMs
189
- };
190
- state.owners.set(deviceId, owner);
191
- activeOwners.add(owner);
192
- } else {
193
- owner.retry = request.retry;
194
- owner.onTerminal = request.onTerminal;
195
- owner.transitionTimeoutMs = transitionTimeoutMs;
196
- if (owner.timer) {
197
- clearTimer(owner.timer);
198
- owner.timer = null;
199
- }
200
- }
201
-
202
- if (owner.attemptCount >= maxAttempts) {
203
- failOwner(owner, 'CAPTURE_TRANSITION_ATTEMPT_LIMIT');
204
- return false;
205
- }
206
-
207
- const retryAfterMs = clamp(
208
- finitePositive(result.retryAfterMs, minimumRetryAfterMs),
209
- minimumRetryAfterMs,
210
- maximumRetryAfterMs
211
- );
212
- const waitToken = ++owner.waitToken;
213
- owner.phase = 'waiting-stop';
214
- const transitionPromise = result.transitionPromise
215
- && typeof result.transitionPromise.then === 'function'
216
- ? result.transitionPromise
217
- : Promise.resolve({
218
- captureStopConfirmed: false,
219
- evidenceUnavailable: true
220
- });
221
-
222
- void Promise.resolve(transitionPromise).then(
223
- settlement => {
224
- if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
225
- return;
226
- }
227
- const remainingMs = owner.deadlineAtEpochMs - now();
228
- const captureStopConfirmed = settlement?.captureStopConfirmed === true;
229
- const requiredRetryBudgetMs = captureStopConfirmed
230
- ? retryAfterMs
231
- : owner.transitionTimeoutMs + retryAfterMs;
232
- if (remainingMs < requiredRetryBudgetMs) {
233
- failOwner(owner, 'CAPTURE_TRANSITION_TIMEOUT', {
234
- captureStopConfirmed,
235
- remainingMs: Math.max(0, remainingMs)
236
- });
237
- return;
238
- }
239
- owner.phase = 'retry-delay';
240
- owner.timer = setTimer(() => {
241
- if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
242
- return;
243
- }
244
- owner.timer = null;
245
- owner.phase = 'retrying';
246
- owner.attemptCount += 1;
247
- const retryContext = {
248
- deviceId: owner.deviceId,
249
- intentGeneration: owner.intentGeneration,
250
- attemptCount: owner.attemptCount,
251
- deadlineAtEpochMs: owner.deadlineAtEpochMs,
252
- captureStopConfirmed
253
- };
254
- void Promise.resolve()
255
- .then(() => owner.retry?.(retryContext))
256
- .then(() => {
257
- if (ownerIsCurrent(owner) && owner.phase === 'retrying') {
258
- failOwner(owner, 'CAPTURE_TRANSITION_RETRY_NOT_SETTLED');
259
- }
260
- })
261
- .catch(error => {
262
- failOwner(owner, 'CAPTURE_TRANSITION_RETRY_FAILED', {
263
- detail: error instanceof Error ? error.message : String(error || '')
264
- });
265
- });
266
- }, Math.min(retryAfterMs, remainingMs));
267
- owner.timer?.unref?.();
268
- },
269
- error => {
270
- failOwner(owner, 'CAPTURE_TRANSITION_WAIT_FAILED', {
271
- detail: error instanceof Error ? error.message : String(error || '')
272
- });
273
- }
274
- );
275
- return true;
276
- }
277
-
278
- function snapshot() {
279
- const owners = [...activeOwners].filter(ownerIsCurrent);
280
- return {
281
- activeOwnerCount: owners.length,
282
- timerCount: owners.filter(owner => !!owner.timer).length,
283
- waitingStopCount: owners.filter(owner => owner.phase === 'waiting-stop').length,
284
- retryingCount: owners.filter(owner => owner.phase === 'retrying').length,
285
- attemptCount: owners.reduce((total, owner) => total + owner.attemptCount, 0),
286
- terminal: owners.length === 0
287
- };
288
- }
289
-
290
- return Object.freeze({
291
- beginIntent,
292
- schedule,
293
- complete,
294
- cancelClient,
295
- snapshot
296
- });
297
- }
1
+ function finitePositive(value, fallback) {
2
+ const number = Number(value);
3
+ return Number.isFinite(number) && number > 0 ? number : fallback;
4
+ }
5
+
6
+ function clamp(value, minimum, maximum) {
7
+ return Math.max(minimum, Math.min(maximum, value));
8
+ }
9
+
10
+ /**
11
+ * Owns the Hub-to-browser retry that follows an exact native capture stop.
12
+ *
13
+ * Contract:
14
+ * - one owner per WebSocket/device intent generation;
15
+ * - the native stop promise is the event source (no polling loop);
16
+ * - retries and total elapsed time are both bounded;
17
+ * - supersede, close, success, and terminal failure synchronously release every timer.
18
+ */
19
+ export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
20
+ const now = typeof options.now === 'function' ? options.now : Date.now;
21
+ const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
22
+ const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
23
+ const maxAttempts = Math.max(1, Math.floor(finitePositive(options.maxAttempts, 3)));
24
+ const minimumRetryAfterMs = Math.max(1, finitePositive(options.minimumRetryAfterMs, 25));
25
+ const maximumRetryAfterMs = Math.max(
26
+ minimumRetryAfterMs,
27
+ finitePositive(options.maximumRetryAfterMs, 250)
28
+ );
29
+ const minimumTotalMs = Math.max(1, finitePositive(options.minimumTotalMs, 2_000));
30
+ const maximumTotalMs = Math.max(minimumTotalMs, finitePositive(options.maximumTotalMs, 30_000));
31
+ const completionMarginMs = Math.max(0, Number(options.completionMarginMs ?? 2_000) || 0);
32
+ const clientStates = new WeakMap();
33
+ const activeOwners = new Set();
34
+
35
+ function stateFor(client) {
36
+ if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
37
+ throw new TypeError('A capture transition retry requires an exact client owner.');
38
+ }
39
+ let state = clientStates.get(client);
40
+ if (!state) {
41
+ state = {
42
+ intentGenerations: new Map(),
43
+ owners: new Map()
44
+ };
45
+ clientStates.set(client, state);
46
+ }
47
+ return state;
48
+ }
49
+
50
+ function ownerIsCurrent(owner) {
51
+ if (!owner || owner.cancelled) {
52
+ return false;
53
+ }
54
+ const state = clientStates.get(owner.client);
55
+ return !!state
56
+ && state.owners.get(owner.deviceId) === owner
57
+ && state.intentGenerations.get(owner.deviceId) === owner.intentGeneration;
58
+ }
59
+
60
+ function releaseOwner(owner, reason = 'completed') {
61
+ if (!owner || owner.cancelled) {
62
+ return false;
63
+ }
64
+ owner.cancelled = true;
65
+ owner.cancelReason = reason;
66
+ owner.waitToken += 1;
67
+ if (owner.timer) {
68
+ clearTimer(owner.timer);
69
+ owner.timer = null;
70
+ }
71
+ const state = clientStates.get(owner.client);
72
+ if (state?.owners.get(owner.deviceId) === owner) {
73
+ state.owners.delete(owner.deviceId);
74
+ }
75
+ activeOwners.delete(owner);
76
+ return true;
77
+ }
78
+
79
+ function failOwner(owner, error, details = {}) {
80
+ if (!ownerIsCurrent(owner)) {
81
+ return false;
82
+ }
83
+ const onTerminal = owner.onTerminal;
84
+ const terminal = {
85
+ deviceId: owner.deviceId,
86
+ intentGeneration: owner.intentGeneration,
87
+ attemptCount: owner.attemptCount,
88
+ deadlineAtEpochMs: owner.deadlineAtEpochMs,
89
+ error,
90
+ ...details
91
+ };
92
+ releaseOwner(owner, error);
93
+ try {
94
+ onTerminal?.(terminal);
95
+ } catch {
96
+ // The retry owner is already terminal. UI notification failure cannot
97
+ // resurrect it or retain a timer.
98
+ }
99
+ return true;
100
+ }
101
+
102
+ function beginIntent(client, deviceId) {
103
+ const normalizedDeviceId = String(deviceId || '').trim();
104
+ if (!normalizedDeviceId) {
105
+ throw new TypeError('A capture transition retry requires a device id.');
106
+ }
107
+ const state = stateFor(client);
108
+ releaseOwner(state.owners.get(normalizedDeviceId), 'superseded');
109
+ const current = Number(state.intentGenerations.get(normalizedDeviceId) || 0);
110
+ const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
111
+ state.intentGenerations.set(normalizedDeviceId, next);
112
+ return next;
113
+ }
114
+
115
+ function complete(client, deviceId, intentGeneration, reason = 'completed') {
116
+ const normalizedDeviceId = String(deviceId || '').trim();
117
+ const state = clientStates.get(client);
118
+ const owner = state?.owners.get(normalizedDeviceId);
119
+ if (!owner
120
+ || (Number.isSafeInteger(Number(intentGeneration))
121
+ && Number(intentGeneration) > 0
122
+ && owner.intentGeneration !== Number(intentGeneration))) {
123
+ return false;
124
+ }
125
+ return releaseOwner(owner, reason);
126
+ }
127
+
128
+ function cancelClient(client, reason = 'client-closed') {
129
+ const state = clientStates.get(client);
130
+ if (!state) {
131
+ return 0;
132
+ }
133
+ let cancelled = 0;
134
+ for (const owner of [...state.owners.values()]) {
135
+ if (releaseOwner(owner, reason)) {
136
+ cancelled += 1;
137
+ }
138
+ }
139
+ state.intentGenerations.clear();
140
+ clientStates.delete(client);
141
+ return cancelled;
142
+ }
143
+
144
+ function schedule(client, request = {}) {
145
+ const deviceId = String(request.deviceId || '').trim();
146
+ const intentGeneration = Number(request.intentGeneration || 0);
147
+ const result = request.result && typeof request.result === 'object'
148
+ ? request.result
149
+ : {};
150
+ const state = stateFor(client);
151
+ if (!deviceId
152
+ || !Number.isSafeInteger(intentGeneration)
153
+ || intentGeneration <= 0
154
+ || state.intentGenerations.get(deviceId) !== intentGeneration
155
+ || result.error !== 'CAPTURE_TRANSITION_IN_PROGRESS') {
156
+ return false;
157
+ }
158
+
159
+ let owner = state.owners.get(deviceId);
160
+ if (owner && owner.intentGeneration !== intentGeneration) {
161
+ releaseOwner(owner, 'superseded');
162
+ owner = null;
163
+ }
164
+ const transitionTimeoutMs = clamp(
165
+ finitePositive(result.transitionTimeoutMs, 11_000),
166
+ minimumRetryAfterMs,
167
+ maximumTotalMs
168
+ );
169
+ if (!owner) {
170
+ const totalWindowMs = clamp(
171
+ transitionTimeoutMs + completionMarginMs,
172
+ minimumTotalMs,
173
+ maximumTotalMs
174
+ );
175
+ owner = {
176
+ client,
177
+ deviceId,
178
+ intentGeneration,
179
+ attemptCount: 0,
180
+ deadlineAtEpochMs: now() + totalWindowMs,
181
+ timer: null,
182
+ phase: 'created',
183
+ cancelled: false,
184
+ cancelReason: '',
185
+ waitToken: 0,
186
+ retry: request.retry,
187
+ onTerminal: request.onTerminal,
188
+ transitionTimeoutMs
189
+ };
190
+ state.owners.set(deviceId, owner);
191
+ activeOwners.add(owner);
192
+ } else {
193
+ owner.retry = request.retry;
194
+ owner.onTerminal = request.onTerminal;
195
+ owner.transitionTimeoutMs = transitionTimeoutMs;
196
+ if (owner.timer) {
197
+ clearTimer(owner.timer);
198
+ owner.timer = null;
199
+ }
200
+ }
201
+
202
+ if (owner.attemptCount >= maxAttempts) {
203
+ failOwner(owner, 'CAPTURE_TRANSITION_ATTEMPT_LIMIT');
204
+ return false;
205
+ }
206
+
207
+ const retryAfterMs = clamp(
208
+ finitePositive(result.retryAfterMs, minimumRetryAfterMs),
209
+ minimumRetryAfterMs,
210
+ maximumRetryAfterMs
211
+ );
212
+ const waitToken = ++owner.waitToken;
213
+ owner.phase = 'waiting-stop';
214
+ const transitionPromise = result.transitionPromise
215
+ && typeof result.transitionPromise.then === 'function'
216
+ ? result.transitionPromise
217
+ : Promise.resolve({
218
+ captureStopConfirmed: false,
219
+ evidenceUnavailable: true
220
+ });
221
+
222
+ void Promise.resolve(transitionPromise).then(
223
+ settlement => {
224
+ if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
225
+ return;
226
+ }
227
+ const remainingMs = owner.deadlineAtEpochMs - now();
228
+ const captureStopConfirmed = settlement?.captureStopConfirmed === true;
229
+ const requiredRetryBudgetMs = captureStopConfirmed
230
+ ? retryAfterMs
231
+ : owner.transitionTimeoutMs + retryAfterMs;
232
+ if (remainingMs < requiredRetryBudgetMs) {
233
+ failOwner(owner, 'CAPTURE_TRANSITION_TIMEOUT', {
234
+ captureStopConfirmed,
235
+ remainingMs: Math.max(0, remainingMs)
236
+ });
237
+ return;
238
+ }
239
+ owner.phase = 'retry-delay';
240
+ owner.timer = setTimer(() => {
241
+ if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
242
+ return;
243
+ }
244
+ owner.timer = null;
245
+ owner.phase = 'retrying';
246
+ owner.attemptCount += 1;
247
+ const retryContext = {
248
+ deviceId: owner.deviceId,
249
+ intentGeneration: owner.intentGeneration,
250
+ attemptCount: owner.attemptCount,
251
+ deadlineAtEpochMs: owner.deadlineAtEpochMs,
252
+ captureStopConfirmed
253
+ };
254
+ void Promise.resolve()
255
+ .then(() => owner.retry?.(retryContext))
256
+ .then(() => {
257
+ if (ownerIsCurrent(owner) && owner.phase === 'retrying') {
258
+ failOwner(owner, 'CAPTURE_TRANSITION_RETRY_NOT_SETTLED');
259
+ }
260
+ })
261
+ .catch(error => {
262
+ failOwner(owner, 'CAPTURE_TRANSITION_RETRY_FAILED', {
263
+ detail: error instanceof Error ? error.message : String(error || '')
264
+ });
265
+ });
266
+ }, Math.min(retryAfterMs, remainingMs));
267
+ owner.timer?.unref?.();
268
+ },
269
+ error => {
270
+ failOwner(owner, 'CAPTURE_TRANSITION_WAIT_FAILED', {
271
+ detail: error instanceof Error ? error.message : String(error || '')
272
+ });
273
+ }
274
+ );
275
+ return true;
276
+ }
277
+
278
+ function snapshot() {
279
+ const owners = [...activeOwners].filter(ownerIsCurrent);
280
+ return {
281
+ activeOwnerCount: owners.length,
282
+ timerCount: owners.filter(owner => !!owner.timer).length,
283
+ waitingStopCount: owners.filter(owner => owner.phase === 'waiting-stop').length,
284
+ retryingCount: owners.filter(owner => owner.phase === 'retrying').length,
285
+ attemptCount: owners.reduce((total, owner) => total + owner.attemptCount, 0),
286
+ terminal: owners.length === 0
287
+ };
288
+ }
289
+
290
+ return Object.freeze({
291
+ beginIntent,
292
+ schedule,
293
+ complete,
294
+ cancelClient,
295
+ snapshot
296
+ });
297
+ }