@goodandready/dsh-tts 0.4.32 → 0.4.33

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.33 (2026-10-11)
4
+
5
+ - **SSE Stream Watchdog**: Added idle timeout watchdog (30s) in Web player (`lib/client-src/20-player.js`) to automatically trigger reconnect on stale/frozen EventSource connection (#206).
6
+ - **Engine Manager Cleanup**: Added explicit `removeEventListener('abort', ...)` in `execute` finally block for model downloads in `lib/engines/manager.js` (#206).
7
+ - **Updater Testing**: Added behavioral test capturing `child_process.spawn` arguments during `/api/dsh-tts/update` execution verifying no bypass of minimum release age (#206).
8
+ - **Client Bundle**: Rebuilt `lib/client.js` from canonical client-src fragments with idle watchdog.
9
+
3
10
  ## 0.4.32
4
11
 
5
12
  ### Fixed
package/lib/client.js CHANGED
@@ -527,17 +527,36 @@ window.__ModuleLoader__.load({
527
527
  return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
528
528
  }
529
529
 
530
- // Unified SSE Realtime Stream Connection (#158)
530
+ // Unified SSE Realtime Stream Connection (#158, #206)
531
531
  let activeSse = null
532
532
  let activeSseAttempt = 0
533
533
  let activeSseTimer = null
534
+ let activeSseWatchdog = null
534
535
  let sseDisposed = false
536
+ const SSE_IDLE_TIMEOUT_MS = 30000
535
537
 
536
538
  function startSseStream() {
537
539
  if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
538
540
  sseDisposed = false
539
541
 
542
+ const resetWatchdog = () => {
543
+ if (activeSseWatchdog) clearTimeout(activeSseWatchdog)
544
+ if (sseDisposed) return
545
+ activeSseWatchdog = setTimeout(() => {
546
+ if (activeSse && !sseDisposed) {
547
+ try {
548
+ if (typeof activeSse.onerror === 'function') {
549
+ activeSse.onerror(new Event('error'))
550
+ } else {
551
+ activeSse.close()
552
+ }
553
+ } catch (_err) { /* error trigger ignore */ }
554
+ }
555
+ }, SSE_IDLE_TIMEOUT_MS)
556
+ }
557
+
540
558
  const onItem = (e) => {
559
+ resetWatchdog()
541
560
  try {
542
561
  const item = JSON.parse(e.data)
543
562
  if (!item || !item.id) return
@@ -556,6 +575,7 @@ window.__ModuleLoader__.load({
556
575
  }
557
576
 
558
577
  const onChime = (e) => {
578
+ resetWatchdog()
559
579
  try {
560
580
  const item = JSON.parse(e.data)
561
581
  if (!item) return
@@ -567,6 +587,7 @@ window.__ModuleLoader__.load({
567
587
 
568
588
  const connect = () => {
569
589
  if (sseDisposed) return
590
+ if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
570
591
  if (activeSse) {
571
592
  try { activeSse.close() } catch (_err) { /* active sse already closed */ }
572
593
  activeSse = null
@@ -574,10 +595,15 @@ window.__ModuleLoader__.load({
574
595
  try {
575
596
  const es = new EventSource('/dsh-tts/stream')
576
597
  activeSse = es
577
- es.onopen = () => { activeSseAttempt = 0 }
598
+ es.onopen = () => {
599
+ activeSseAttempt = 0
600
+ resetWatchdog()
601
+ }
602
+ es.onmessage = () => { resetWatchdog() }
578
603
  es.addEventListener('utterance', onItem)
579
604
  es.addEventListener('chime', onChime)
580
605
  es.onerror = () => {
606
+ if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
581
607
  if (activeSse === es) {
582
608
  try { es.close() } catch (_err) { /* eventsource already closed */ }
583
609
  activeSse = null
@@ -588,6 +614,7 @@ window.__ModuleLoader__.load({
588
614
  if (activeSseTimer) clearTimeout(activeSseTimer)
589
615
  activeSseTimer = setTimeout(connect, delay)
590
616
  }
617
+ resetWatchdog()
591
618
  } catch { /* EventSource failed */ }
592
619
  }
593
620
 
@@ -595,7 +622,8 @@ window.__ModuleLoader__.load({
595
622
 
596
623
  return () => {
597
624
  sseDisposed = true
598
- if (activeSseTimer) clearTimeout(activeSseTimer)
625
+ if (activeSseTimer) { clearTimeout(activeSseTimer); activeSseTimer = null }
626
+ if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
599
627
  if (activeSse) {
600
628
  try { activeSse.close() } catch (_err) { /* active sse already closed on dispose */ }
601
629
  activeSse = null
@@ -197,9 +197,13 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
197
197
  const dir = getModelPath(engineId)
198
198
 
199
199
  const controller = new AbortController()
200
+ let onAbortListener = null
200
201
  if (options.signal) {
201
202
  if (options.signal.aborted) controller.abort()
202
- else options.signal.addEventListener('abort', () => controller.abort(), { once: true })
203
+ else {
204
+ onAbortListener = () => controller.abort()
205
+ options.signal.addEventListener('abort', onAbortListener, { once: true })
206
+ }
203
207
  }
204
208
 
205
209
  const downloadState = {
@@ -311,6 +315,9 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
311
315
  lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
312
316
  throw err
313
317
  } finally {
318
+ if (options.signal && onAbortListener) {
319
+ try { options.signal.removeEventListener('abort', onAbortListener) } catch (_err) { /* listener already removed */ }
320
+ }
314
321
  if (activeDownloads.get(engineId) === downloadState) {
315
322
  activeDownloads.delete(engineId)
316
323
  }
package/lib/updater.js CHANGED
@@ -125,7 +125,7 @@ function runtime() {
125
125
  : validProfileName(basename(profileDir))
126
126
  ? basename(profileDir)
127
127
  : 'web'
128
- const cliEntry = findDshCliEntry()
128
+ const cliEntry = process.env.DSH_CLI_ENTRY ?? findDshCliEntry()
129
129
  return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
130
130
  }
131
131
 
@@ -170,7 +170,10 @@ export function isNewerVersion(currentValue, candidateValue) {
170
170
  return comparePrerelease(candidate.prerelease, current.prerelease) > 0
171
171
  }
172
172
 
173
- async function latestVersion(packageName, registry) {
173
+ async function latestVersion(packageName, registry, options) {
174
+ if (typeof options?.latestVersionImpl === 'function') {
175
+ return await options.latestVersionImpl(packageName, registry)
176
+ }
174
177
  if (
175
178
  latestCache?.packageName === packageName &&
176
179
  latestCache.registry === registry &&
@@ -202,7 +205,7 @@ async function currentVersion(manifestUrl) {
202
205
 
203
206
  export async function status(options, target) {
204
207
  const current = await currentVersion(options.manifestUrl)
205
- const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
208
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org', options)
206
209
  return {
207
210
  packageName: options.packageName,
208
211
  currentVersion: current,
@@ -217,7 +220,25 @@ export async function status(options, target) {
217
220
  async function installExact(target, packageSpec, options) {
218
221
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
219
222
  await new Promise((resolvePromise, reject) => {
220
- const child = spawn(
223
+ const spawnCall = options?.spawnImpl || spawn
224
+ const child = spawnCall === spawn ? spawn(
225
+ process.execPath,
226
+ [
227
+ target.cliEntry,
228
+ 'plugin',
229
+ '--profile',
230
+ target.profileName,
231
+ 'add',
232
+ packageSpec,
233
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
234
+ ],
235
+ {
236
+ cwd: target.profileDir,
237
+ windowsHide: true,
238
+ stdio: ['ignore', 'pipe', 'pipe'],
239
+ env: { ...process.env, NO_COLOR: '1' },
240
+ }
241
+ ) : spawnCall(
221
242
  process.execPath,
222
243
  [
223
244
  target.cliEntry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.32",
3
+ "version": "0.4.33",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",