beads-ui 0.3.0 → 0.4.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/CHANGES.md +26 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -16
- package/server/app.js +39 -35
- package/server/bd.js +6 -2
- package/server/cli/commands.js +12 -8
- package/server/cli/daemon.js +20 -5
- package/server/cli/index.js +19 -31
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +4 -2
- package/server/config.js +3 -2
- package/server/db.js +9 -6
- package/server/index.js +10 -4
- package/server/list-adapters.js +9 -3
- package/server/logging.js +23 -0
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +10 -5
- package/server/ws.js +31 -10
- package/app/data/list-selectors.js +0 -98
- package/app/data/providers.js +0 -76
- package/app/data/sort.js +0 -45
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -102
- package/app/data/subscriptions-store.js +0 -219
- package/app/main.js +0 -702
- package/app/protocol.js +0 -196
- package/app/protocol.md +0 -66
- package/app/router.js +0 -114
- package/app/state.js +0 -103
- package/app/utils/issue-id-renderer.js +0 -71
- package/app/utils/issue-id.js +0 -10
- package/app/utils/issue-type.js +0 -27
- package/app/utils/issue-url.js +0 -9
- package/app/utils/markdown.js +0 -22
- package/app/utils/priority-badge.js +0 -47
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -32
- package/app/utils/status.js +0 -23
- package/app/utils/toast.js +0 -34
- package/app/utils/type-badge.js +0 -33
- package/app/views/board.js +0 -535
- package/app/views/detail.js +0 -1249
- package/app/views/epics.js +0 -280
- package/app/views/issue-dialog.js +0 -163
- package/app/views/issue-row.js +0 -190
- package/app/views/list.js +0 -464
- package/app/views/nav.js +0 -67
- package/app/views/new-issue-dialog.js +0 -345
- package/app/ws.js +0 -279
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
2
|
-
import { priority_levels } from '../utils/priority.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Create and manage the New Issue dialog (native <dialog>).
|
|
6
|
-
* @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
|
|
7
|
-
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
|
|
8
|
-
* @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
|
|
9
|
-
* @param {{ setState: (patch: any) => void, getState: () => any }} [store]
|
|
10
|
-
* @returns {{ open: () => void, close: () => void }}
|
|
11
|
-
*/
|
|
12
|
-
export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
13
|
-
const dialog = /** @type {HTMLDialogElement} */ (
|
|
14
|
-
document.createElement('dialog')
|
|
15
|
-
);
|
|
16
|
-
dialog.id = 'new-issue-dialog';
|
|
17
|
-
dialog.setAttribute('role', 'dialog');
|
|
18
|
-
dialog.setAttribute('aria-modal', 'true');
|
|
19
|
-
|
|
20
|
-
dialog.innerHTML = `
|
|
21
|
-
<div class="new-issue__container" part="container">
|
|
22
|
-
<header class="new-issue__header">
|
|
23
|
-
<div class="new-issue__title">New Issue</div>
|
|
24
|
-
<button type="button" class="new-issue__close" aria-label="Close">×</button>
|
|
25
|
-
</header>
|
|
26
|
-
<div class="new-issue__body">
|
|
27
|
-
<form id="new-issue-form" class="new-issue__form">
|
|
28
|
-
<label for="new-title">Title</label>
|
|
29
|
-
<input id="new-title" name="title" type="text" required placeholder="Short summary" />
|
|
30
|
-
|
|
31
|
-
<label for="new-type">Type</label>
|
|
32
|
-
<select id="new-type" name="type" aria-label="Issue type"></select>
|
|
33
|
-
|
|
34
|
-
<label for="new-priority">Priority</label>
|
|
35
|
-
<select id="new-priority" name="priority" aria-label="Priority"></select>
|
|
36
|
-
|
|
37
|
-
<label for="new-labels">Labels</label>
|
|
38
|
-
<input id="new-labels" name="labels" type="text" placeholder="comma,separated" />
|
|
39
|
-
|
|
40
|
-
<label for="new-description">Description</label>
|
|
41
|
-
<textarea id="new-description" name="description" rows="6" placeholder="Optional markdown description"></textarea>
|
|
42
|
-
|
|
43
|
-
<div aria-live="polite" role="status" class="new-issue__error" id="new-issue-error"></div>
|
|
44
|
-
|
|
45
|
-
<div class="new-issue__actions" style="grid-column: 1 / -1">
|
|
46
|
-
<button type="button" id="btn-cancel">Cancel (Esc)</button>
|
|
47
|
-
<button type="submit" id="btn-create">Create</button>
|
|
48
|
-
</div>
|
|
49
|
-
</form>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
`;
|
|
53
|
-
|
|
54
|
-
mount_element.appendChild(dialog);
|
|
55
|
-
|
|
56
|
-
const form = /** @type {HTMLFormElement} */ (
|
|
57
|
-
dialog.querySelector('#new-issue-form')
|
|
58
|
-
);
|
|
59
|
-
const input_title = /** @type {HTMLInputElement} */ (
|
|
60
|
-
dialog.querySelector('#new-title')
|
|
61
|
-
);
|
|
62
|
-
const sel_type = /** @type {HTMLSelectElement} */ (
|
|
63
|
-
dialog.querySelector('#new-type')
|
|
64
|
-
);
|
|
65
|
-
const sel_priority = /** @type {HTMLSelectElement} */ (
|
|
66
|
-
dialog.querySelector('#new-priority')
|
|
67
|
-
);
|
|
68
|
-
const input_labels = /** @type {HTMLInputElement} */ (
|
|
69
|
-
dialog.querySelector('#new-labels')
|
|
70
|
-
);
|
|
71
|
-
const input_description = /** @type {HTMLTextAreaElement} */ (
|
|
72
|
-
dialog.querySelector('#new-description')
|
|
73
|
-
);
|
|
74
|
-
const error_box = /** @type {HTMLDivElement} */ (
|
|
75
|
-
dialog.querySelector('#new-issue-error')
|
|
76
|
-
);
|
|
77
|
-
const btn_cancel = /** @type {HTMLButtonElement} */ (
|
|
78
|
-
dialog.querySelector('#btn-cancel')
|
|
79
|
-
);
|
|
80
|
-
const btn_create = /** @type {HTMLButtonElement} */ (
|
|
81
|
-
dialog.querySelector('#btn-create')
|
|
82
|
-
);
|
|
83
|
-
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
84
|
-
dialog.querySelector('.new-issue__close')
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
// Populate selects
|
|
88
|
-
function populateSelects() {
|
|
89
|
-
sel_type.replaceChildren();
|
|
90
|
-
// Empty option to allow leaving type unspecified
|
|
91
|
-
const optEmpty = document.createElement('option');
|
|
92
|
-
optEmpty.value = '';
|
|
93
|
-
optEmpty.textContent = '— Select —';
|
|
94
|
-
sel_type.appendChild(optEmpty);
|
|
95
|
-
for (const t of ISSUE_TYPES) {
|
|
96
|
-
const o = document.createElement('option');
|
|
97
|
-
o.value = t;
|
|
98
|
-
o.textContent = typeLabel(t);
|
|
99
|
-
sel_type.appendChild(o);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
sel_priority.replaceChildren();
|
|
103
|
-
for (let i = 0; i <= 4; i += 1) {
|
|
104
|
-
const o = document.createElement('option');
|
|
105
|
-
o.value = String(i);
|
|
106
|
-
const label = priority_levels[i] || 'Medium';
|
|
107
|
-
o.textContent = `${i} – ${label}`;
|
|
108
|
-
sel_priority.appendChild(o);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
populateSelects();
|
|
112
|
-
|
|
113
|
-
function requestClose() {
|
|
114
|
-
try {
|
|
115
|
-
if (typeof dialog.close === 'function') {
|
|
116
|
-
dialog.close();
|
|
117
|
-
} else {
|
|
118
|
-
dialog.removeAttribute('open');
|
|
119
|
-
}
|
|
120
|
-
} catch {
|
|
121
|
-
dialog.removeAttribute('open');
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* @param {boolean} is_busy
|
|
127
|
-
*/
|
|
128
|
-
function setBusy(is_busy) {
|
|
129
|
-
input_title.disabled = is_busy;
|
|
130
|
-
sel_type.disabled = is_busy;
|
|
131
|
-
sel_priority.disabled = is_busy;
|
|
132
|
-
input_labels.disabled = is_busy;
|
|
133
|
-
input_description.disabled = is_busy;
|
|
134
|
-
btn_cancel.disabled = is_busy;
|
|
135
|
-
btn_create.disabled = is_busy;
|
|
136
|
-
btn_create.textContent = is_busy ? 'Creating…' : 'Create';
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function clearError() {
|
|
140
|
-
error_box.textContent = '';
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* @param {string} msg
|
|
145
|
-
*/
|
|
146
|
-
function setError(msg) {
|
|
147
|
-
error_box.textContent = msg;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function loadDefaults() {
|
|
151
|
-
try {
|
|
152
|
-
const t = window.localStorage.getItem('beads-ui.new.type');
|
|
153
|
-
if (t) {
|
|
154
|
-
sel_type.value = t;
|
|
155
|
-
} else {
|
|
156
|
-
sel_type.value = '';
|
|
157
|
-
}
|
|
158
|
-
const p = window.localStorage.getItem('beads-ui.new.priority');
|
|
159
|
-
if (p && /^\d$/.test(p)) {
|
|
160
|
-
sel_priority.value = p;
|
|
161
|
-
} else {
|
|
162
|
-
sel_priority.value = '2';
|
|
163
|
-
}
|
|
164
|
-
} catch {
|
|
165
|
-
sel_type.value = '';
|
|
166
|
-
sel_priority.value = '2';
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function saveDefaults() {
|
|
171
|
-
try {
|
|
172
|
-
const t = sel_type.value || '';
|
|
173
|
-
const p = sel_priority.value || '';
|
|
174
|
-
if (t.length > 0) {
|
|
175
|
-
window.localStorage.setItem('beads-ui.new.type', t);
|
|
176
|
-
}
|
|
177
|
-
if (p.length > 0) {
|
|
178
|
-
window.localStorage.setItem('beads-ui.new.priority', p);
|
|
179
|
-
}
|
|
180
|
-
} catch {
|
|
181
|
-
// ignore persistence errors
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Extract numeric suffix from an id like "UI-123"; return -1 when absent.
|
|
187
|
-
* @param {string} id
|
|
188
|
-
*/
|
|
189
|
-
function idNumeric(id) {
|
|
190
|
-
const m = /-(\d+)$/.exec(String(id || ''));
|
|
191
|
-
return m && m[1] ? Number(m[1]) : -1;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Submit handler: validate, create, then open the created issue details.
|
|
196
|
-
* @returns {Promise<void>}
|
|
197
|
-
*/
|
|
198
|
-
async function createNow() {
|
|
199
|
-
clearError();
|
|
200
|
-
const title = String(input_title.value || '').trim();
|
|
201
|
-
if (title.length === 0) {
|
|
202
|
-
setError('Title is required');
|
|
203
|
-
input_title.focus();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
const prio = Number(sel_priority.value || '2');
|
|
207
|
-
if (!(prio >= 0 && prio <= 4)) {
|
|
208
|
-
setError('Priority must be 0..4');
|
|
209
|
-
sel_priority.focus();
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const type = String(sel_type.value || '');
|
|
213
|
-
const desc = String(input_description.value || '');
|
|
214
|
-
const labels = String(input_labels.value || '')
|
|
215
|
-
.split(',')
|
|
216
|
-
.map((s) => s.trim())
|
|
217
|
-
.filter((s) => s.length > 0);
|
|
218
|
-
|
|
219
|
-
/** @type {{ title: string, type?: string, priority?: number, description?: string }} */
|
|
220
|
-
const payload = { title };
|
|
221
|
-
if (type.length > 0) {
|
|
222
|
-
payload.type = type;
|
|
223
|
-
}
|
|
224
|
-
if (String(prio).length > 0) {
|
|
225
|
-
payload.priority = prio;
|
|
226
|
-
}
|
|
227
|
-
if (desc.length > 0) {
|
|
228
|
-
payload.description = desc;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
setBusy(true);
|
|
232
|
-
try {
|
|
233
|
-
await sendFn('create-issue', payload);
|
|
234
|
-
} catch {
|
|
235
|
-
setBusy(false);
|
|
236
|
-
setError('Failed to create issue');
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
saveDefaults();
|
|
241
|
-
|
|
242
|
-
// Best-effort: find the created id by matching title among open issues and picking the highest numeric id
|
|
243
|
-
/** @type {any} */
|
|
244
|
-
let list = null;
|
|
245
|
-
try {
|
|
246
|
-
list = await sendFn('list-issues', {
|
|
247
|
-
filters: { status: 'open', limit: 50 }
|
|
248
|
-
});
|
|
249
|
-
} catch {
|
|
250
|
-
list = null;
|
|
251
|
-
}
|
|
252
|
-
let created_id = '';
|
|
253
|
-
if (Array.isArray(list)) {
|
|
254
|
-
const matches = list.filter((it) => String(it.title || '') === title);
|
|
255
|
-
if (matches.length > 0) {
|
|
256
|
-
/** @type {any} */
|
|
257
|
-
let best = matches[0];
|
|
258
|
-
for (const it of matches) {
|
|
259
|
-
const ai = idNumeric(best.id || '');
|
|
260
|
-
const bi = idNumeric(it.id || '');
|
|
261
|
-
if (bi > ai) {
|
|
262
|
-
best = it;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
created_id = String(best.id || '');
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Apply labels if any
|
|
270
|
-
if (created_id && labels.length > 0) {
|
|
271
|
-
for (const label of labels) {
|
|
272
|
-
try {
|
|
273
|
-
await sendFn('label-add', { id: created_id, label });
|
|
274
|
-
} catch {
|
|
275
|
-
// ignore label failures
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Navigate to created issue if found
|
|
281
|
-
if (created_id) {
|
|
282
|
-
try {
|
|
283
|
-
router.gotoIssue(created_id);
|
|
284
|
-
} catch {
|
|
285
|
-
// ignore routing errors
|
|
286
|
-
}
|
|
287
|
-
// Also set state directly to ensure dialog opens even if hash routing is suppressed in tests
|
|
288
|
-
try {
|
|
289
|
-
if (store) {
|
|
290
|
-
store.setState({ selected_id: created_id });
|
|
291
|
-
}
|
|
292
|
-
} catch {
|
|
293
|
-
// ignore
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
setBusy(false);
|
|
298
|
-
requestClose();
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Events
|
|
302
|
-
dialog.addEventListener('cancel', (ev) => {
|
|
303
|
-
ev.preventDefault();
|
|
304
|
-
requestClose();
|
|
305
|
-
});
|
|
306
|
-
btn_close.addEventListener('click', () => requestClose());
|
|
307
|
-
btn_cancel.addEventListener('click', () => requestClose());
|
|
308
|
-
dialog.addEventListener('keydown', (ev) => {
|
|
309
|
-
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
|
310
|
-
ev.preventDefault();
|
|
311
|
-
void createNow();
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
form.addEventListener('submit', (ev) => {
|
|
315
|
-
ev.preventDefault();
|
|
316
|
-
void createNow();
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
return {
|
|
320
|
-
open() {
|
|
321
|
-
form.reset();
|
|
322
|
-
clearError();
|
|
323
|
-
loadDefaults();
|
|
324
|
-
try {
|
|
325
|
-
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
326
|
-
dialog.showModal();
|
|
327
|
-
} else {
|
|
328
|
-
dialog.setAttribute('open', '');
|
|
329
|
-
}
|
|
330
|
-
} catch {
|
|
331
|
-
dialog.setAttribute('open', '');
|
|
332
|
-
}
|
|
333
|
-
setTimeout(() => {
|
|
334
|
-
try {
|
|
335
|
-
input_title.focus();
|
|
336
|
-
} catch {
|
|
337
|
-
// ignore
|
|
338
|
-
}
|
|
339
|
-
}, 0);
|
|
340
|
-
},
|
|
341
|
-
close() {
|
|
342
|
-
requestClose();
|
|
343
|
-
}
|
|
344
|
-
};
|
|
345
|
-
}
|
package/app/ws.js
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/* global Console */
|
|
2
|
-
/**
|
|
3
|
-
* @import { MessageType } from './protocol.js'
|
|
4
|
-
*/
|
|
5
|
-
/**
|
|
6
|
-
* Persistent WebSocket client with reconnect, request/response correlation,
|
|
7
|
-
* and simple event dispatching.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* const ws = createWsClient();
|
|
11
|
-
* const data = await ws.send('list-issues', { filters: {} });
|
|
12
|
-
* const off = ws.on('snapshot', (payload) => { <push event> });
|
|
13
|
-
*/
|
|
14
|
-
import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @typedef {'connecting'|'open'|'closed'|'reconnecting'} ConnectionState
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitterRatio?: number }} BackoffOptions
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* @typedef {{ url?: string, backoff?: BackoffOptions, logger?: Console }} ClientOptions
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Create a WebSocket client with auto-reconnect and message correlation.
|
|
30
|
-
* @param {ClientOptions} [options]
|
|
31
|
-
*/
|
|
32
|
-
export function createWsClient(options = {}) {
|
|
33
|
-
/** @type {Console} */
|
|
34
|
-
const logger = options.logger || console;
|
|
35
|
-
|
|
36
|
-
/** @type {BackoffOptions} */
|
|
37
|
-
const backoff = {
|
|
38
|
-
initialMs: options.backoff?.initialMs ?? 1000,
|
|
39
|
-
maxMs: options.backoff?.maxMs ?? 30000,
|
|
40
|
-
factor: options.backoff?.factor ?? 2,
|
|
41
|
-
jitterRatio: options.backoff?.jitterRatio ?? 0.2
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** @type {() => string} */
|
|
45
|
-
const resolveUrl = () => {
|
|
46
|
-
if (options.url && options.url.length > 0) {
|
|
47
|
-
return options.url;
|
|
48
|
-
}
|
|
49
|
-
if (typeof location !== 'undefined') {
|
|
50
|
-
return (
|
|
51
|
-
(location.protocol === 'https:' ? 'wss://' : 'ws://') +
|
|
52
|
-
location.host +
|
|
53
|
-
'/ws'
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
return 'ws://localhost/ws';
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/** @type {WebSocket | null} */
|
|
60
|
-
let ws = null;
|
|
61
|
-
/** @type {ConnectionState} */
|
|
62
|
-
let state = 'closed';
|
|
63
|
-
/** @type {number} */
|
|
64
|
-
let attempts = 0;
|
|
65
|
-
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
66
|
-
let reconnect_timer = null;
|
|
67
|
-
/** @type {boolean} */
|
|
68
|
-
let should_reconnect = true;
|
|
69
|
-
|
|
70
|
-
/** @type {Map<string, { resolve: (v: any) => void, reject: (e: any) => void, type: string }>} */
|
|
71
|
-
const pending = new Map();
|
|
72
|
-
/** @type {Array<ReturnType<typeof makeRequest>>} */
|
|
73
|
-
const queue = [];
|
|
74
|
-
/** @type {Map<string, Set<(payload: any) => void>>} */
|
|
75
|
-
const handlers = new Map();
|
|
76
|
-
/** @type {Set<(s: ConnectionState) => void>} */
|
|
77
|
-
const connection_handlers = new Set();
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* @param {ConnectionState} s
|
|
81
|
-
*/
|
|
82
|
-
function notifyConnection(s) {
|
|
83
|
-
for (const fn of Array.from(connection_handlers)) {
|
|
84
|
-
try {
|
|
85
|
-
fn(s);
|
|
86
|
-
} catch {
|
|
87
|
-
// ignore listener errors
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function scheduleReconnect() {
|
|
93
|
-
if (!should_reconnect || reconnect_timer) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
state = 'reconnecting';
|
|
97
|
-
notifyConnection(state);
|
|
98
|
-
const base = Math.min(
|
|
99
|
-
backoff.maxMs || 0,
|
|
100
|
-
(backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
|
|
101
|
-
);
|
|
102
|
-
const jitter = (backoff.jitterRatio || 0) * base;
|
|
103
|
-
const delay = Math.max(
|
|
104
|
-
0,
|
|
105
|
-
Math.round(base + (Math.random() * 2 - 1) * jitter)
|
|
106
|
-
);
|
|
107
|
-
reconnect_timer = setTimeout(() => {
|
|
108
|
-
reconnect_timer = null;
|
|
109
|
-
connect();
|
|
110
|
-
}, delay);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** @param {ReturnType<typeof makeRequest>} req */
|
|
114
|
-
function sendRaw(req) {
|
|
115
|
-
try {
|
|
116
|
-
ws?.send(JSON.stringify(req));
|
|
117
|
-
} catch (err) {
|
|
118
|
-
logger.error('ws send failed', err);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function onOpen() {
|
|
123
|
-
state = 'open';
|
|
124
|
-
notifyConnection(state);
|
|
125
|
-
attempts = 0;
|
|
126
|
-
// flush queue
|
|
127
|
-
while (queue.length) {
|
|
128
|
-
const req = queue.shift();
|
|
129
|
-
if (req) {
|
|
130
|
-
sendRaw(req);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** @param {MessageEvent} ev */
|
|
136
|
-
function onMessage(ev) {
|
|
137
|
-
/** @type {any} */
|
|
138
|
-
let msg;
|
|
139
|
-
try {
|
|
140
|
-
msg = JSON.parse(String(ev.data));
|
|
141
|
-
} catch {
|
|
142
|
-
logger.warn('ws received non-JSON message');
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (!msg || typeof msg.id !== 'string' || typeof msg.type !== 'string') {
|
|
146
|
-
logger.warn('ws received invalid envelope');
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (pending.has(msg.id)) {
|
|
151
|
-
const entry = pending.get(msg.id);
|
|
152
|
-
pending.delete(msg.id);
|
|
153
|
-
if (msg.ok) {
|
|
154
|
-
entry?.resolve(msg.payload);
|
|
155
|
-
} else {
|
|
156
|
-
entry?.reject(msg.error || new Error('ws error'));
|
|
157
|
-
}
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Treat as server-initiated event
|
|
162
|
-
const set = handlers.get(msg.type);
|
|
163
|
-
if (set && set.size > 0) {
|
|
164
|
-
for (const fn of Array.from(set)) {
|
|
165
|
-
try {
|
|
166
|
-
fn(msg.payload);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
logger.error('ws event handler error', err);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
} else {
|
|
172
|
-
logger.warn(`ws received unhandled message type: ${msg.type}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function onClose() {
|
|
177
|
-
state = 'closed';
|
|
178
|
-
notifyConnection(state);
|
|
179
|
-
// fail all pending
|
|
180
|
-
for (const [id, p] of pending.entries()) {
|
|
181
|
-
p.reject(new Error('ws disconnected'));
|
|
182
|
-
pending.delete(id);
|
|
183
|
-
}
|
|
184
|
-
attempts += 1;
|
|
185
|
-
scheduleReconnect();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function connect() {
|
|
189
|
-
if (!should_reconnect) {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const url = resolveUrl();
|
|
193
|
-
try {
|
|
194
|
-
ws = new WebSocket(url);
|
|
195
|
-
state = 'connecting';
|
|
196
|
-
notifyConnection(state);
|
|
197
|
-
ws.addEventListener('open', onOpen);
|
|
198
|
-
ws.addEventListener('message', onMessage);
|
|
199
|
-
ws.addEventListener('error', () => {
|
|
200
|
-
// let close handler handle reconnect
|
|
201
|
-
});
|
|
202
|
-
ws.addEventListener('close', onClose);
|
|
203
|
-
} catch (err) {
|
|
204
|
-
logger.error('ws connect failed', err);
|
|
205
|
-
scheduleReconnect();
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
connect();
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
/**
|
|
213
|
-
* Send a request and await its correlated reply payload.
|
|
214
|
-
* @param {MessageType} type
|
|
215
|
-
* @param {unknown} [payload]
|
|
216
|
-
* @returns {Promise<any>}
|
|
217
|
-
*/
|
|
218
|
-
send(type, payload) {
|
|
219
|
-
if (!MESSAGE_TYPES.includes(type)) {
|
|
220
|
-
return Promise.reject(new Error(`unknown message type: ${type}`));
|
|
221
|
-
}
|
|
222
|
-
const id = nextId();
|
|
223
|
-
const req = makeRequest(type, payload, id);
|
|
224
|
-
return new Promise((resolve, reject) => {
|
|
225
|
-
pending.set(id, { resolve, reject, type });
|
|
226
|
-
if (ws && ws.readyState === ws.OPEN) {
|
|
227
|
-
sendRaw(req);
|
|
228
|
-
} else {
|
|
229
|
-
queue.push(req);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
},
|
|
233
|
-
/**
|
|
234
|
-
* Register a handler for a server-initiated event type.
|
|
235
|
-
* Returns an unsubscribe function.
|
|
236
|
-
* @param {MessageType} type
|
|
237
|
-
* @param {(payload: any) => void} handler
|
|
238
|
-
* @returns {() => void}
|
|
239
|
-
*/
|
|
240
|
-
on(type, handler) {
|
|
241
|
-
if (!handlers.has(type)) {
|
|
242
|
-
handlers.set(type, new Set());
|
|
243
|
-
}
|
|
244
|
-
const set = handlers.get(type);
|
|
245
|
-
set?.add(handler);
|
|
246
|
-
return () => {
|
|
247
|
-
set?.delete(handler);
|
|
248
|
-
};
|
|
249
|
-
},
|
|
250
|
-
/**
|
|
251
|
-
* Subscribe to connection state changes.
|
|
252
|
-
* @param {(state: ConnectionState) => void} handler
|
|
253
|
-
* @returns {() => void}
|
|
254
|
-
*/
|
|
255
|
-
onConnection(handler) {
|
|
256
|
-
connection_handlers.add(handler);
|
|
257
|
-
return () => {
|
|
258
|
-
connection_handlers.delete(handler);
|
|
259
|
-
};
|
|
260
|
-
},
|
|
261
|
-
/** Close and stop reconnecting. */
|
|
262
|
-
close() {
|
|
263
|
-
should_reconnect = false;
|
|
264
|
-
if (reconnect_timer) {
|
|
265
|
-
clearTimeout(reconnect_timer);
|
|
266
|
-
reconnect_timer = null;
|
|
267
|
-
}
|
|
268
|
-
try {
|
|
269
|
-
ws?.close();
|
|
270
|
-
} catch {
|
|
271
|
-
/* ignore */
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
/** For diagnostics in tests or UI. */
|
|
275
|
-
getState() {
|
|
276
|
-
return state;
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
}
|