@goodandready/dsh-tts 0.4.31 → 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 +23 -0
- package/lib/cache.js +25 -22
- package/lib/client.js +33 -5
- package/lib/engines/manager.js +27 -12
- package/lib/routes.js +11 -1
- package/lib/stream-hub.js +1 -1
- package/lib/updater.js +25 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.4.32
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Transactional Rollback on Credential Failure** (#151):
|
|
14
|
+
- Added transactional rollback to previous config in `/dsh-tts/config` PUT handler if credential persistence throws an error after settings replacement, ensuring consistent state on aborted saves.
|
|
15
|
+
- **Messenger Gateway / Speak Guard Strictness** (#165):
|
|
16
|
+
- Enforced 403 rejection for unclassified / external gateway requests lacking channel metadata when `messengerTtsEnabled` is disabled, while preserving internal web UI and voice duplex traffic.
|
|
17
|
+
- **Strict Multi-Format Model Integrity Validation** (#179):
|
|
18
|
+
- Enhanced `validateFileIntegrity` to reject uniform repeating byte sequences of any value, strictly validate JSON parsing for voice mappings, enforce ONNX protobuf header magic (`0x08`), and validate safetensors header length bounds.
|
|
19
|
+
- **Non-Tautological Test Suite Enforcement** (#181):
|
|
20
|
+
- Replaced tautological and assertion-free test fixtures in deduplication, client prebuffering, and SSE player suites with strict assertions verifying exact execution counts (`synthCalls === 1`), factory invocation, and queue limits.
|
|
21
|
+
- **Immediate Pre-Write SSE Chunk Cap** (#186):
|
|
22
|
+
- Added immediate buffer cap rejection before `res.write` in `safeWrite`, preventing oversized payloads (>512 KB) from entering the socket buffer even on the initial chunk.
|
|
23
|
+
- **Cache L2 Clear Concurrency Guard** (#202):
|
|
24
|
+
- Captured generation token at the immediate start of `get()`, preventing post-clear metadata recreation when disk reads are interleaved with cache clearing.
|
|
25
|
+
|
|
3
26
|
## 0.4.31
|
|
4
27
|
|
|
5
28
|
### Fixed
|
package/lib/cache.js
CHANGED
|
@@ -137,6 +137,7 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
137
137
|
},
|
|
138
138
|
|
|
139
139
|
async get(key) {
|
|
140
|
+
const currentGen = generation
|
|
140
141
|
// 1. L1 RAM Hit
|
|
141
142
|
if (l1Map.has(key)) {
|
|
142
143
|
const hit = l1Map.get(key)
|
|
@@ -145,15 +146,16 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
145
146
|
l1Map.set(key, { ...hit, at: now })
|
|
146
147
|
const d = diskEntries.get(key)
|
|
147
148
|
if (d) d.at = now
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
149
|
+
if (currentGen === generation) {
|
|
150
|
+
const writePromise = (async () => {
|
|
151
|
+
if (currentGen !== generation) return
|
|
152
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
|
|
153
|
+
if (currentGen !== generation) {
|
|
154
|
+
await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
|
|
155
|
+
}
|
|
156
|
+
})().catch(() => {})
|
|
157
|
+
trackWrite(writePromise)
|
|
158
|
+
}
|
|
157
159
|
return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
|
|
158
160
|
}
|
|
159
161
|
|
|
@@ -162,19 +164,20 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
162
164
|
try {
|
|
163
165
|
const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
|
|
164
166
|
const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
167
|
+
if (currentGen === generation) {
|
|
168
|
+
putL1(key, meta.mime, audio)
|
|
169
|
+
const now = Date.now()
|
|
170
|
+
const d = diskEntries.get(key)
|
|
171
|
+
if (d) d.at = now
|
|
172
|
+
const writePromise = (async () => {
|
|
173
|
+
if (currentGen !== generation) return
|
|
174
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
|
|
175
|
+
if (currentGen !== generation) {
|
|
176
|
+
await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
|
|
177
|
+
}
|
|
178
|
+
})().catch(() => {})
|
|
179
|
+
trackWrite(writePromise)
|
|
180
|
+
}
|
|
178
181
|
return { mime: meta.mime || 'audio/mpeg', audio }
|
|
179
182
|
} catch (miss) {
|
|
180
183
|
return null
|
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 = () => {
|
|
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
|
|
@@ -674,7 +702,7 @@ window.__ModuleLoader__.load({
|
|
|
674
702
|
clientFetch('/dsh-tts/speak', {
|
|
675
703
|
method: 'POST',
|
|
676
704
|
headers: { 'Content-Type': 'application/json' },
|
|
677
|
-
body: JSON.stringify({ text }),
|
|
705
|
+
body: JSON.stringify({ text, channel: 'web' }),
|
|
678
706
|
timeoutMs: 15000,
|
|
679
707
|
})
|
|
680
708
|
.then((r) => r.json())
|
|
@@ -727,7 +755,7 @@ window.__ModuleLoader__.load({
|
|
|
727
755
|
clientFetch('/dsh-tts/speak', {
|
|
728
756
|
method: 'POST',
|
|
729
757
|
headers: { 'Content-Type': 'application/json' },
|
|
730
|
-
body: JSON.stringify({ text: phrase }),
|
|
758
|
+
body: JSON.stringify({ text: phrase, channel: 'web' }),
|
|
731
759
|
timeoutMs: 15000,
|
|
732
760
|
})
|
|
733
761
|
.then((r) => r.json())
|
package/lib/engines/manager.js
CHANGED
|
@@ -63,8 +63,8 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
63
63
|
return false
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Reject repetitive dummy bytes (e.g. 2048 bytes of 0x2a)
|
|
67
|
-
if (buf.length >= 2048
|
|
66
|
+
// Reject repetitive dummy bytes (e.g. 2048 bytes of 0x41, 0x2a, etc.)
|
|
67
|
+
if (buf.length >= 2048) {
|
|
68
68
|
let allSame = true
|
|
69
69
|
for (let i = 1; i < buf.length; i++) {
|
|
70
70
|
if (buf[i] !== buf[0]) {
|
|
@@ -75,16 +75,24 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
75
75
|
if (allSame) return false
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
//
|
|
78
|
+
// Format-specific verification
|
|
79
79
|
if (filePath.endsWith('.json')) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
try {
|
|
81
|
+
const content = await fs.promises.readFile(filePath, 'utf8')
|
|
82
|
+
const parsed = JSON.parse(content)
|
|
83
|
+
if (!parsed || typeof parsed !== 'object') return false
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
} else if (filePath.endsWith('.safetensors')) {
|
|
88
|
+
if (st.size < 16) return false
|
|
89
|
+
const headerLen = buf.readBigUInt64LE(0)
|
|
90
|
+
if (headerLen <= 0n || headerLen > 100n * 1024n * 1024n || BigInt(st.size) < 8n + headerLen) {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
} else if (filePath.endsWith('.onnx')) {
|
|
94
|
+
if (buf[0] !== 0x08) {
|
|
95
|
+
return false
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
|
|
@@ -189,9 +197,13 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
189
197
|
const dir = getModelPath(engineId)
|
|
190
198
|
|
|
191
199
|
const controller = new AbortController()
|
|
200
|
+
let onAbortListener = null
|
|
192
201
|
if (options.signal) {
|
|
193
202
|
if (options.signal.aborted) controller.abort()
|
|
194
|
-
else
|
|
203
|
+
else {
|
|
204
|
+
onAbortListener = () => controller.abort()
|
|
205
|
+
options.signal.addEventListener('abort', onAbortListener, { once: true })
|
|
206
|
+
}
|
|
195
207
|
}
|
|
196
208
|
|
|
197
209
|
const downloadState = {
|
|
@@ -303,6 +315,9 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
303
315
|
lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
|
|
304
316
|
throw err
|
|
305
317
|
} finally {
|
|
318
|
+
if (options.signal && onAbortListener) {
|
|
319
|
+
try { options.signal.removeEventListener('abort', onAbortListener) } catch (_err) { /* listener already removed */ }
|
|
320
|
+
}
|
|
306
321
|
if (activeDownloads.get(engineId) === downloadState) {
|
|
307
322
|
activeDownloads.delete(engineId)
|
|
308
323
|
}
|
package/lib/routes.js
CHANGED
|
@@ -388,13 +388,23 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
388
388
|
if (parsed && typeof parsed === 'object') {
|
|
389
389
|
delete parsed.revision
|
|
390
390
|
}
|
|
391
|
+
let replaceSucceeded = false
|
|
392
|
+
const prevConfig = typeof settingsApi.get === 'function' ? settingsApi.get() : null
|
|
391
393
|
try {
|
|
392
394
|
await settingsApi.replace(parsed, revision)
|
|
395
|
+
replaceSucceeded = true
|
|
393
396
|
for (const item of keys) {
|
|
394
397
|
await storeProviderKey(item.provider, item.value)
|
|
395
398
|
}
|
|
396
399
|
writeJson(res, 200, await configResponse())
|
|
397
400
|
} catch (e) {
|
|
401
|
+
if (replaceSucceeded && prevConfig) {
|
|
402
|
+
try {
|
|
403
|
+
await settingsApi.replace(prevConfig)
|
|
404
|
+
} catch (_rollbackErr) {
|
|
405
|
+
// best-effort rollback on credential storage failure
|
|
406
|
+
}
|
|
407
|
+
}
|
|
398
408
|
const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || (
|
|
399
409
|
/conflict/i.test(e?.message || '') || (/revision/i.test(e?.message || '') && !/not volatile/i.test(e?.message || ''))
|
|
400
410
|
)
|
|
@@ -486,7 +496,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
486
496
|
return
|
|
487
497
|
}
|
|
488
498
|
const isWeb = payload.channel === 'web' || payload.source === 'web' || req.headers['x-dsh-channel'] === 'web'
|
|
489
|
-
const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
|
|
499
|
+
const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger' || (!isVoice && !isWeb)
|
|
490
500
|
if (isMessenger && !cfg.messengerTtsEnabled) {
|
|
491
501
|
writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
|
|
492
502
|
return
|
package/lib/stream-hub.js
CHANGED
|
@@ -25,7 +25,7 @@ function safeWrite(res, payload) {
|
|
|
25
25
|
? res.writableLength
|
|
26
26
|
: (res.__dtsBufferedBytes || 0)
|
|
27
27
|
|
|
28
|
-
if (res.__dtsBackpressured &&
|
|
28
|
+
if (payloadBytes > MAX_SUBSCRIBER_BUFFER || (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) || currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER * 2) {
|
|
29
29
|
subscribers.delete(res)
|
|
30
30
|
detach(res)
|
|
31
31
|
try { res.end() } catch (_err) { /* already ended */ }
|
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
|
|
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.
|
|
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",
|