@goodandready/dsh-tts 0.4.0 → 0.4.2

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/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { createModelManager } from './engines/manager.js'
11
11
  import { registerHttpRoutes } from './routes.js'
12
12
  import { broadcastStream, clearStreamSubscribers } from './stream-hub.js'
13
13
  import { writeJson } from './http-util.js'
14
+ import { createProviderBreaker } from './breaker.js'
14
15
  import {
15
16
  CLOUD_PROVIDERS,
16
17
  assertCredentialRef,
@@ -126,11 +127,11 @@ export const Config = z.object({
126
127
  approvalText: z
127
128
  .string()
128
129
  .description('What to say when an approval is asked for. The tool name is appended.')
129
- .default('Требуется подтверждение'),
130
+ .default('Approval required'),
130
131
  questionText: z
131
132
  .string()
132
133
  .description('What to say when the agent asks a question. Empty disables it.')
133
- .default('Агент задал вопрос'),
134
+ .default('Agent asked a question'),
134
135
  chime: z
135
136
  .string()
136
137
  .description('Short sound before an announcement: ding, beep or none.')
@@ -177,6 +178,8 @@ export const Config = z.object({
177
178
  let utteranceSeq = 0
178
179
 
179
180
  export function apply(ctx, config) {
181
+ const providerBreaker = createProviderBreaker()
182
+
180
183
  let getConfig = () => config
181
184
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
182
185
 
@@ -338,7 +341,7 @@ export function apply(ctx, config) {
338
341
  try {
339
342
  const retold = await summarizeReply(text.slice(0, 8000), cfg, signal)
340
343
  if (retold) cut = speechPhrases(cfg.language).summaryIntro + ' ' + retold
341
- } catch (llmDown) { /* тихий откат к обрезке */ }
344
+ } catch (llmDown) { /* quiet fallback to truncation */ }
342
345
  }
343
346
  text = cut
344
347
  }
@@ -380,10 +383,28 @@ export function apply(ctx, config) {
380
383
 
381
384
  const synthPromise = (async () => {
382
385
  try {
383
- const providers = makeProviders(
386
+ const baseProviders = makeProviders(
384
387
  { resolveKey, fetchImpl: fetch, cfg, modelManager },
385
388
  { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
386
389
  )
390
+ const providers = {}
391
+ for (const key of Object.keys(baseProviders)) {
392
+ const fn = baseProviders[key]
393
+ providers[key] = async () => {
394
+ if (providerBreaker.isOpen(key)) {
395
+ return { ok: false, provider: key, reason: 'circuit open (cooldown)' }
396
+ }
397
+ try {
398
+ const out = await fn()
399
+ if (out && out.ok) providerBreaker.recordSuccess(key)
400
+ else providerBreaker.recordFailure(key, out && out.reason)
401
+ return out
402
+ } catch (err) {
403
+ providerBreaker.recordFailure(key, err)
404
+ throw err
405
+ }
406
+ }
407
+ }
387
408
  const out = await runChain(order, providers)
388
409
  stats.total++
389
410
  const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
@@ -549,6 +570,8 @@ export function apply(ctx, config) {
549
570
 
550
571
 
551
572
  registerHttpRoutes(ctx, {
573
+ breakerSnapshot: () => providerBreaker.snapshot(),
574
+
552
575
  live,
553
576
  modelManager,
554
577
  stats,
@@ -560,6 +583,8 @@ export function apply(ctx, config) {
560
583
  storeProviderKey,
561
584
  clearProviderKey,
562
585
  pending,
586
+ getSettingsApi: () => settingsApi,
587
+ validateConfig: (obj) => Config(obj),
563
588
  })
564
589
 
565
590
  ctx.effect(() => () => {
package/lib/local.js CHANGED
@@ -83,15 +83,13 @@ export async function edgeSpeak(text, { bin, voice, timeoutMs }) {
83
83
 
84
84
 
85
85
  /**
86
- * MiniMax через их консольную утилиту.
86
+ * MiniMax via their official CLI utility.
87
87
  *
88
- * Прямого HTTP тут намеренно нет: у MiniMax свой протокол с отдельным
89
- * идентификатором группы и своей формой ответа, а официальная утилита уже
90
- * умеет и авторизацию, и выбор голоса. Она же хранит вход — плагину не нужно
91
- * знать ни ключа, ни устройства их API.
88
+ * There is intentionally no direct HTTP here: MiniMax uses its own protocol with a
89
+ * separate group id and response shape, while the official CLI already handles auth
90
+ * and voice selection. It also stores the login — the plugin never sees the key.
92
91
  *
93
- * Утилита ставится отдельно и один раз логинится; без неё провайдер честно
94
- * отказывает, и цепочка идёт дальше.
92
+ * The utility is installed separately and logs in once; without it the provider fails honestly and the chain moves on.
95
93
  */
96
94
  export async function minimaxSpeak(text, { bin, voice, model, timeoutMs }) {
97
95
  const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-minimax-'))
@@ -0,0 +1,61 @@
1
+ export const PROVIDER_KEYS = [
2
+ 'kokoro', 'f5',
3
+ 'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
4
+ 'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
5
+ 'edge', 'piper', 'espeak', 'minimax',
6
+ ]
7
+
8
+ // OpenAI-compatible providers share /audio/speech; they differ by base URL,
9
+ // model, and key name. Each URL was probed without a key and returned 401.
10
+ export const OPENAI_COMPATIBLE = {
11
+ siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
12
+ deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
13
+ fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
14
+ // Custom endpoint: base URL and key name come from settings.
15
+ custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
16
+ }
17
+
18
+ export const DEFAULT_MODELS = {
19
+ kokoro: 'hexgrad/Kokoro-82M',
20
+ f5: 'F5-TTS',
21
+ mimo: 'mimo-v2.5-tts',
22
+ minimax: '',
23
+ siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
24
+ deepinfra: 'hexgrad/Kokoro-82M',
25
+ fireworks: 'kokoro',
26
+ openai: 'gpt-4o-mini-tts',
27
+ elevenlabs: 'eleven_multilingual_v2',
28
+ google: 'gemini-2.5-flash-preview-tts',
29
+ azure: 'en-US-JennyNeural',
30
+ groq: 'playai-tts',
31
+ deepgram: 'aura-asteria-en',
32
+ openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
33
+ custom: '',
34
+ edge: 'ru-RU-SvetlanaNeural',
35
+ piper: '',
36
+ espeak: 'ru',
37
+ }
38
+
39
+ export const DEFAULT_VOICES = {
40
+ kokoro: 'af_bella',
41
+ f5: 'default',
42
+ // MiMo voices use Chinese names; 冰糖 is their default.
43
+ mimo: '冰糖',
44
+ minimax: '',
45
+ siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
46
+ deepinfra: 'af_bella',
47
+ fireworks: 'af_bella',
48
+ openai: 'alloy',
49
+ elevenlabs: '21m00Tcm4TlvDq8ikWAM',
50
+ google: 'Kore',
51
+ azure: 'en-US-JennyNeural',
52
+ groq: 'Fritz-PlayAI',
53
+ deepgram: '',
54
+ openrouter: 'alloy',
55
+ custom: '',
56
+ edge: 'ru-RU-SvetlanaNeural',
57
+ piper: '',
58
+ espeak: 'ru',
59
+ }
60
+
61
+
package/lib/providers.js CHANGED
@@ -1,66 +1,8 @@
1
1
  import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from './local.js'
2
2
  import { KokoroEngine } from './engines/kokoro.js'
3
3
  import { F5Engine } from './engines/f5.js'
4
-
5
- export const PROVIDER_KEYS = [
6
- 'kokoro', 'f5',
7
- 'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
8
- 'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
9
- 'edge', 'piper', 'espeak', 'minimax',
10
- ]
11
-
12
- // OpenAI-compatible providers share /audio/speech; they differ by base URL,
13
- // model, and key name. Each URL was probed without a key and returned 401.
14
- export const OPENAI_COMPATIBLE = {
15
- siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
16
- deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
17
- fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
18
- // Custom endpoint: base URL and key name come from settings.
19
- custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
20
- }
21
-
22
- export const DEFAULT_MODELS = {
23
- kokoro: 'hexgrad/Kokoro-82M',
24
- f5: 'F5-TTS',
25
- mimo: 'mimo-v2.5-tts',
26
- minimax: '',
27
- siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
28
- deepinfra: 'hexgrad/Kokoro-82M',
29
- fireworks: 'kokoro',
30
- openai: 'gpt-4o-mini-tts',
31
- elevenlabs: 'eleven_multilingual_v2',
32
- google: 'gemini-2.5-flash-preview-tts',
33
- azure: 'en-US-JennyNeural',
34
- groq: 'playai-tts',
35
- deepgram: 'aura-asteria-en',
36
- openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
37
- custom: '',
38
- edge: 'ru-RU-SvetlanaNeural',
39
- piper: '',
40
- espeak: 'ru',
41
- }
42
-
43
- export const DEFAULT_VOICES = {
44
- kokoro: 'af_bella',
45
- f5: 'default',
46
- // MiMo voices use Chinese names; 冰糖 is their default.
47
- mimo: '冰糖',
48
- minimax: '',
49
- siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
50
- deepinfra: 'af_bella',
51
- fireworks: 'af_bella',
52
- openai: 'alloy',
53
- elevenlabs: '21m00Tcm4TlvDq8ikWAM',
54
- google: 'Kore',
55
- azure: 'en-US-JennyNeural',
56
- groq: 'Fritz-PlayAI',
57
- deepgram: '',
58
- openrouter: 'alloy',
59
- custom: '',
60
- edge: 'ru-RU-SvetlanaNeural',
61
- piper: '',
62
- espeak: 'ru',
63
- }
4
+ import { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES } from './providers/constants.js'
5
+ export { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES }
64
6
 
65
7
  function pick(map, models, key) {
66
8
  const chosen = models && typeof models[key] === 'string' ? models[key].trim() : ''
@@ -179,7 +121,7 @@ export function makeProviders(deps, req) {
179
121
  && data.choices[0].message && data.choices[0].message.audio
180
122
  && data.choices[0].message.audio.data
181
123
  if (typeof encoded !== 'string' || !encoded) {
182
- return { ok: false, provider: 'mimo', reason: 'в ответе нет choices[0].message.audio.data' }
124
+ return { ok: false, provider: 'mimo', reason: 'response missing choices[0].message.audio.data' }
183
125
  }
184
126
  const audio = Buffer.from(encoded, 'base64')
185
127
  return {
package/lib/routes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
2
- import { addStreamSubscriber, removeStreamSubscriber } from './stream-hub.js'
2
+ import { addStreamSubscriber, removeStreamSubscriber, streamSubscriberCount } from './stream-hub.js'
3
3
  import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
4
4
  import { PROVIDER_KEYS } from './providers.js'
5
5
 
@@ -21,6 +21,9 @@ export function registerHttpRoutes(ctx, api) {
21
21
  storeProviderKey,
22
22
  clearProviderKey,
23
23
  pending,
24
+ getSettingsApi,
25
+ validateConfig,
26
+ breakerSnapshot,
24
27
  } = api
25
28
  ctx.effect(() => ctx.webServer.register({
26
29
  kind: 'exact',
@@ -42,6 +45,8 @@ export function registerHttpRoutes(ctx, api) {
42
45
  chain: cfg.chain,
43
46
  roles: cfg.roles,
44
47
  providers: PROVIDER_KEYS,
48
+ sseClients: streamSubscriberCount(),
49
+ breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [],
45
50
  })
46
51
  },
47
52
  }), 'dsh-tts: /status route')
@@ -107,7 +112,11 @@ export function registerHttpRoutes(ctx, api) {
107
112
  // Fire installation in background or await initial start
108
113
  modelManager.installModel(engine).catch(() => {})
109
114
  const current = await modelManager.getStatus(engine)
110
- writeJson(res, 200, { ok: true, model: current })
115
+ writeJson(res, 200, {
116
+ ok: true,
117
+ model: current,
118
+ note: 'Weights only. Neural inference runtime is not bundled; speech still requires Edge, Piper, or eSpeak.',
119
+ })
111
120
  } catch (err) {
112
121
  writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
113
122
  }
@@ -130,7 +139,7 @@ export function registerHttpRoutes(ctx, api) {
130
139
  try {
131
140
  const raw = await readBody(req, 16 * 1024)
132
141
  body = JSON.parse(raw.toString('utf8') || '{}')
133
- } catch { body = {} }
142
+ } catch { /* missing body treated as empty */ body = {} }
134
143
  const engine = String(body.engine || '').trim()
135
144
  if (!engine) {
136
145
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
@@ -158,7 +167,7 @@ export function registerHttpRoutes(ctx, api) {
158
167
  voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
159
168
  messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
160
169
  }
161
- } catch {}
170
+ } catch { /* ignore malformed client frame */ }
162
171
 
163
172
  // Runtime routes are the only reliable signal. Do not probe ../ siblings
164
173
  // or node_modules layouts — they lie outside a normal npm install.
@@ -235,7 +244,7 @@ export function registerHttpRoutes(ctx, api) {
235
244
  path: '/dsh-tts/stats',
236
245
  handler: async (req, res) => {
237
246
  if (req.method === 'GET') {
238
- writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
247
+ writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers, breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [] })
239
248
  return
240
249
  }
241
250
  if (req.method !== 'DELETE') {
@@ -270,6 +279,7 @@ export function registerHttpRoutes(ctx, api) {
270
279
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
271
280
  return
272
281
  }
282
+ const settingsApi = typeof getSettingsApi === 'function' ? getSettingsApi() : null
273
283
  if (!settingsApi) {
274
284
  writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
275
285
  return
@@ -280,7 +290,7 @@ export function registerHttpRoutes(ctx, api) {
280
290
  return
281
291
  }
282
292
  let payload
283
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
293
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */
284
294
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
285
295
  return
286
296
  }
@@ -290,7 +300,7 @@ export function registerHttpRoutes(ctx, api) {
290
300
  const keys = pendingKeyWrites(payload && payload.keys)
291
301
  const stripped = stripSecretsFromConfig(payload)
292
302
  let parsed
293
- try { parsed = Config(stripped) } catch (e) {
303
+ try { parsed = validateConfig(stripped) } catch (e) {
294
304
  writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
295
305
  return
296
306
  }
@@ -325,7 +335,7 @@ export function registerHttpRoutes(ctx, api) {
325
335
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
326
336
  return
327
337
  }
328
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
338
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */
329
339
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
330
340
  return
331
341
  }
@@ -375,7 +385,7 @@ export function registerHttpRoutes(ctx, api) {
375
385
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
376
386
  }
377
387
  let payload
378
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
388
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */ /* invalid JSON treated as empty */ payload = {} }
379
389
  const text = cleanText(payload.text, live())
380
390
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
381
391
  const cfg = live()
package/lib/stream-hub.js CHANGED
@@ -1,31 +1,83 @@
1
1
  // SSE subscriber hub for /dsh-tts/stream.
2
2
 
3
3
  const subscribers = new Set()
4
+ let heartbeatTimer = null
5
+
6
+ function detach(res) {
7
+ try {
8
+ if (res.__dtsOnClose) res.off('close', res.__dtsOnClose)
9
+ if (res.__dtsOnError) res.off('error', res.__dtsOnError)
10
+ } catch { /* listeners may already be gone */ }
11
+ res.__dtsOnClose = null
12
+ res.__dtsOnError = null
13
+ }
14
+
15
+ function safeWrite(res, payload) {
16
+ try {
17
+ res.write(payload)
18
+ return true
19
+ } catch {
20
+ subscribers.delete(res)
21
+ detach(res)
22
+ return false
23
+ }
24
+ }
25
+
26
+ function ensureHeartbeat() {
27
+ if (heartbeatTimer || typeof setInterval !== 'function') return
28
+ heartbeatTimer = setInterval(() => {
29
+ if (!subscribers.size) {
30
+ clearInterval(heartbeatTimer)
31
+ heartbeatTimer = null
32
+ return
33
+ }
34
+ for (const res of [...subscribers]) {
35
+ safeWrite(res, ': ping\n\n')
36
+ }
37
+ }, 15000)
38
+ if (heartbeatTimer.unref) heartbeatTimer.unref()
39
+ }
4
40
 
5
41
  export function addStreamSubscriber(res) {
6
42
  subscribers.add(res)
43
+ const onClose = () => {
44
+ subscribers.delete(res)
45
+ detach(res)
46
+ }
47
+ res.__dtsOnClose = onClose
48
+ res.__dtsOnError = onClose
49
+ try {
50
+ res.on('close', onClose)
51
+ res.on('error', onClose)
52
+ } catch { /* non-node response object in tests */ }
53
+ ensureHeartbeat()
7
54
  }
8
55
 
9
56
  export function removeStreamSubscriber(res) {
10
57
  subscribers.delete(res)
58
+ detach(res)
11
59
  }
12
60
 
13
61
  export function broadcastStream(type, item) {
14
- const payload = `event: ${type}\ndata: ${JSON.stringify(item)}\n\n`
15
- for (const res of subscribers) {
16
- try {
17
- res.write(payload)
18
- } catch {
19
- subscribers.delete(res)
20
- }
62
+ const payload = 'event: ' + type + '\ndata: ' + JSON.stringify(item) + '\n\n'
63
+ for (const res of [...subscribers]) {
64
+ safeWrite(res, payload)
21
65
  }
22
66
  }
23
67
 
68
+ export function streamSubscriberCount() {
69
+ return subscribers.size
70
+ }
71
+
24
72
  export function clearStreamSubscribers() {
25
- for (const res of subscribers) {
73
+ for (const res of [...subscribers]) {
26
74
  try {
27
75
  res.end()
28
76
  } catch { /* already closed */ }
29
77
  }
30
78
  subscribers.clear()
31
- }
79
+ if (heartbeatTimer) {
80
+ clearInterval(heartbeatTimer)
81
+ heartbeatTimer = null
82
+ }
83
+ }
package/lib/text.js CHANGED
@@ -30,7 +30,7 @@ export function applyNarrationFilters(text, cfg) {
30
30
  if (cfg && cfg.removeRegex) {
31
31
  try {
32
32
  out = out.replace(new RegExp(cfg.removeRegex, 'g'), ' ')
33
- } catch (badUserRegex) { /* кривая регулярка не роняет озвучку */ }
33
+ } catch (badUserRegex) { /* bad user regex must not break speech */ }
34
34
  }
35
35
  if (cfg && cfg.skipActions) out = out.replace(/(^|\s)\*[^*\n]+\*/g, '$1')
36
36
  if (cfg && cfg.narrateQuotesOnly) {
@@ -100,7 +100,7 @@ export function applyPronunciation(text, rules, language, enableBuiltinIt = fals
100
100
  else if (rule.whole) re = new RegExp('(?<![\\p{L}\\p{N}])' + escapeRe(rule.from) + '(?![\\p{L}\\p{N}])', 'gu')
101
101
  else re = new RegExp(escapeRe(rule.from), 'g')
102
102
  out = out.replace(re, to)
103
- } catch (badRule) { /* кривое правило не роняет озвучку */ }
103
+ } catch (badRule) { /* bad rule must not break speech */ }
104
104
  }
105
105
  return out
106
106
  }
@@ -174,7 +174,7 @@ export function restoreAbbreviations(text) {
174
174
  }
175
175
 
176
176
  /**
177
- * Режет текст на куски по границам предложений.
177
+ * Split text into chunks on sentence boundaries.
178
178
  *
179
179
  * Нужно, чтобы чтение начиналось почти сразу: длинный ответ синтезируется
180
180
  * целиком долго, а первое предложение — быстро. Куски короче нижнего порога
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -41,7 +41,9 @@
41
41
  "url": "https://github.com/GooDAnDReaDY/dsh-tts/issues"
42
42
  },
43
43
  "scripts": {
44
- "test": "node --test test/*.test.mjs"
44
+ "test": "node --test test/*.test.mjs",
45
+ "build:client": "node scripts/build-client.mjs",
46
+ "pretest": "node scripts/build-client.mjs"
45
47
  },
46
48
  "dsh": {
47
49
  "bundle": {
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Concatenate lib/client-src fragments into lib/client.js (single ModuleLoader entry).
3
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
8
+ const srcDir = path.join(root, 'lib', 'client-src')
9
+ const outFile = path.join(root, 'lib', 'client.js')
10
+
11
+ const files = (await readdir(srcDir)).filter((f) => f.endsWith('.js')).sort()
12
+ if (files.length === 0) throw new Error('no client-src fragments')
13
+ let out = ''
14
+ for (const f of files) {
15
+ out += await readFile(path.join(srcDir, f), 'utf8')
16
+ if (!out.endsWith('\n')) out += '\n'
17
+ }
18
+ await writeFile(outFile, out, 'utf8')
19
+ console.log(`built lib/client.js from ${files.length} fragments (${out.length} bytes)`)