← Files CastReaderARCHIVED FILE
assets/voice-app/server.mjs
24.3 KB · Sep 30, 2026 · 23:16 UTC
/** Local Node.js 22+ app. The browser never supplies a key, path or upstream URL. */
import { createServer } from 'node:http';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, mkdir, rename, readdir, lstat, open, unlink } from 'node:fs/promises';
import { constants } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { runCards, validateCards } from './generate.mjs';
const root = import.meta.dirname;
const languageNames = { en: 'English', zh: '中文', ja: '日本語', es: 'Español', de: 'Deutsch', fr: 'Français', ko: '한국어', pt: 'Português', ru: 'Русский', it: 'Italiano' };
const uuid = '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}';
const batchRoute = new RegExp(`^/api/batches/(${uuid})(?:/(start|manifest|archive|audio/([a-z0-9][a-z0-9-]{0,70})))?$`);
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
const statuses = new Set(['queued', 'running', 'succeeded', 'failed', 'expired', 'cancelled']);
class AppError extends Error {
constructor(status, code, message) { super(message); Object.assign(this, { status, code }); }
}
const fail = (status, code, message) => { throw new AppError(status, code, message); };
const safeErrors = {
invalid_api_key: 'Check CASTREADER_API_KEY in the server environment.',
expired_api_key: 'Update the expired key in the server environment.',
insufficient_scope: 'The key needs speech:generate, voices:read and usage:read permissions.',
insufficient_balance: 'The current balance cannot cover this input. No top-up was attempted.',
voice_not_found: 'This voice is unavailable to the configured key.',
voice_language_unverified: 'The selected voice is not enabled for one of these languages.',
text_language_mismatch: 'Check that each language matches its text.',
unsupported_language: 'One of these languages is unavailable in this region.',
rate_limit_exceeded: 'The API rate limit was reached. Wait before resuming the same batch.',
admission_paused: 'New work is paused by the service. Keep this batch for later.',
};
function publicError(error) {
if (error instanceof AppError) return { code: error.code, message: error.message };
// Never relay SDK messages, request headers, response bodies or stack traces.
const code = Object.hasOwn(safeErrors, error?.code) ? error.code : 'operation_stopped';
return { code, message: safeErrors[code] || 'Operation stopped. Keep this batch and resume its original jobs; check the API console if it remains stopped.' };
}
function fields(value, allowed) {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(400, 'invalid_input', 'Unexpected request fields.');
}
function cardsInput(value) {
if (!Array.isArray(value)) fail(400, 'invalid_input', 'Use an array of card rows.');
for (const row of value) {
fields(row, ['id', 'text', 'language']);
if (typeof row.id !== 'string' || Object.hasOwn(Object.prototype, row.id)) fail(400, 'invalid_input', 'Every card needs a unique, non-reserved text ID.');
}
try { return validateCards(value); }
catch { fail(400, 'invalid_input', 'Use 1–200 rows with unique lowercase IDs, supported languages and 1–500 normalized characters each.'); }
}
async function readJson(path) {
try { return JSON.parse(await readFile(path, 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
async function atomicJson(path, value) {
const temporary = `${path}.tmp-${randomUUID()}`;
await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
await rename(temporary, path);
}
async function privateDirectory(path) {
await mkdir(path, { recursive: true, mode: 0o700 });
const info = await lstat(path);
if (!info.isDirectory() || info.isSymbolicLink()) fail(409, 'unsafe_directory', 'The app data directory must be a real directory, not a symbolic link.');
}
async function bodyJson(request) {
if (!/^application\/json(?:;\s*charset=utf-8)?$/i.test(request.headers['content-type'] || '')) fail(415, 'content_type', 'Use application/json.');
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 512 * 1024) fail(413, 'body_limit', 'The request exceeds 512 KiB.');
chunks.push(chunk);
}
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
catch { fail(400, 'invalid_json', 'Use valid UTF-8 JSON.'); }
}
const microUSD = value => {
if (typeof value !== 'string' || !/^\d{1,10}\.\d{6}$/.test(value)) fail(502, 'invalid_estimate', 'The API returned an invalid estimate.');
return BigInt(value.replace('.', ''));
};
const usd = value => `${value / 1000000n}.${String(value % 1000000n).padStart(6, '0')}`;
// ZIP store format, no external executable or library. Entries come only from verified files.
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function zip(entries) {
let offset = 0;
const parts = [], central = [];
for (const { name, bytes } of entries) {
const filename = Buffer.from(name);
const crc = crc32(bytes);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x800, 6);
local.writeUInt16LE(33, 12); local.writeUInt32LE(crc, 14);
local.writeUInt32LE(bytes.length, 18); local.writeUInt32LE(bytes.length, 22); local.writeUInt16LE(filename.length, 26);
const record = Buffer.alloc(46);
record.writeUInt32LE(0x02014b50); record.writeUInt16LE(20, 4); record.writeUInt16LE(20, 6); record.writeUInt16LE(0x800, 8);
record.writeUInt16LE(33, 14); record.writeUInt32LE(crc, 16);
record.writeUInt32LE(bytes.length, 20); record.writeUInt32LE(bytes.length, 24); record.writeUInt16LE(filename.length, 28); record.writeUInt32LE(offset, 42);
parts.push(local, filename, bytes); central.push(record, filename);
offset += local.length + filename.length + bytes.length;
}
const directory = Buffer.concat(central), end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, directory, end]);
}
/** Injection options are for offline tests, never accepted from HTTP or environment URLs. */
export async function startServer({ port = 8791, dataDir = join(homedir(), '.castreader-flashcards'), client: injectedClient, waitMs = 3000, timeoutMs = 660000 } = {}) {
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('Use a valid local port.');
await privateDirectory(dataDir);
const batchesDir = join(dataDir, 'batches');
await privateDirectory(batchesDir);
const lockPath = join(dataDir, '.server-running');
let lock;
try { lock = await open(lockPath, 'wx', 0o600); }
catch (error) { if (error.code === 'EEXIST') fail(409, 'server_locked', 'Another app may be running. Stop it before removing the .server-running lock described in README.md.'); throw error; }
await lock.writeFile(String(process.pid));
const token = randomBytes(32).toString('hex');
let clientPromise, active = null, closing = false, origin;
const progress = new Map();
function client() {
if (injectedClient) return Promise.resolve(injectedClient);
if (!process.env.CASTREADER_API_KEY || /[\r\n]/.test(process.env.CASTREADER_API_KEY)) fail(503, 'missing_key', 'Set CASTREADER_API_KEY in your terminal, then restart the app.');
clientPromise ||= import('@castreader/voice-api').then(({ VoiceAPI }) => new VoiceAPI({ maxRetries: 0 })).catch(() => {
clientPromise = null;
fail(503, 'sdk_unavailable', 'Run npm install in the extracted starter folder, then restart the app.');
});
return clientPromise;
}
async function catalog() {
const api = await client();
const [models, voices] = await Promise.all([api.models(), api.voices()]);
const model = models.data?.find(item => item.id === 'clone-v1');
if (!Array.isArray(model?.languages) || !Number.isSafeInteger(model.max_characters) || model.max_characters < 1 || !Array.isArray(voices.data)) fail(502, 'invalid_catalog', 'The API returned an invalid model or voice list.');
return {
languages: Object.keys(languageNames).filter(language => model.languages.includes(language)),
maxCharacters: Math.min(500, model.max_characters),
voices: voices.data.filter(voice => voice.status === 'ready' && /^voice_[a-f0-9]{32}$/.test(voice.id)).map(voice => ({ id: voice.id, name: String(voice.name || 'Ready voice').slice(0, 100), default: voice.default_voice_key === 'narrator' })),
// The SDK lists the first page; never pretend a missing voice is authorized.
hasMoreVoices: voices.has_more === true,
};
}
async function batch(id) {
const dir = join(batchesDir, id);
try { if (!(await lstat(dir)).isDirectory() || (await lstat(dir)).isSymbolicLink()) throw new Error(); }
catch { fail(404, 'not_found', 'Batch not found.'); }
if ((await readdir(dir, { withFileTypes: true })).some(entry => entry.isSymbolicLink())) fail(409, 'unsafe_directory', 'A batch contains a symbolic link. Restore its original regular files before continuing.');
const config = await readJson(join(dir, 'batch.json'));
if (!config || config.id !== id) fail(404, 'not_found', 'Batch not found.');
return { dir, config };
}
async function snapshot(id) {
const { dir, config } = await batch(id);
const state = await readJson(join(dir, 'state.json'));
const execution = await readJson(join(dir, 'execution.json'));
const rows = await Promise.all(config.cards.map(async card => {
const row = state?.rows?.[card.id];
const file = await lstat(join(dir, `${card.id}.mp3`)).catch(() => null);
const saved = Boolean(row?.sha256 && file?.isFile() && !file.isSymbolicLink() && file.size === row.bytes);
return { ...card, status: saved ? 'saved' : progress.get(id)?.[card.id] || (row?.status === 'succeeded' ? 'download_pending' : row?.status) || (row?.jobId ? 'pending' : row?.idempotencyKey ? 'submission_unknown' : 'not_started'), saved, sha256: saved ? row.sha256 : null, bytes: saved ? row.bytes : null, chargedUSD: saved ? row.chargedUSD : null };
}));
const completed = rows.filter(row => row.saved).length;
return { ...config, rows, completed, status: active?.id === id ? 'running' : completed === rows.length ? 'complete' : execution ? 'paused' : 'planned', error: execution?.error || null };
}
async function audio(dir, card, row) {
if (!row?.sha256 || !/^[a-f0-9]{64}$/.test(row.sha256)) fail(409, 'audio_missing', 'This clip is not saved yet. Resume its original batch.');
let file;
try { file = await open(join(dir, `${card.id}.mp3`), constants.O_RDONLY | constants.O_NOFOLLOW); }
catch { fail(409, 'audio_missing', 'The local clip is missing. Resume its original batch to recover the original result.'); }
try {
const info = await file.stat();
if (!info.isFile() || info.size > 16 * 1024 * 1024) fail(409, 'invalid_audio', 'The local clip is not a valid saved file.');
const bytes = await file.readFile();
if (hash(bytes) !== row.sha256) fail(409, 'checksum_mismatch', 'The local clip failed its checksum. Resume this batch to download the original result.');
return bytes;
} finally { await file.close(); }
}
async function exportEntries(id, includeAudio) {
const { dir, config } = await batch(id);
const state = await readJson(join(dir, 'state.json'));
const entries = [], rows = [];
let size = 0;
for (const card of config.cards) {
const row = state?.rows?.[card.id];
const bytes = await audio(dir, card, row);
size += bytes.length;
if (includeAudio && size > 128 * 1024 * 1024) fail(413, 'archive_limit', 'The batch exceeds the 128 MiB ZIP limit. Download individual clips and the manifest.');
rows.push({ ...card, file: `${card.id}.mp3`, sha256: row.sha256, bytes: bytes.length, chargedUSD: row.chargedUSD, trialCharacters: row.trialCharacters, billableCharacters: row.billableCharacters });
if (includeAudio) entries.push({ name: `${card.id}.mp3`, bytes });
}
const manifest = { version: 1, voice: 'Your selected voice', model: 'clone-v1', outputFormat: 'mp3', rows };
entries.unshift({ name: 'manifest.json', bytes: Buffer.from(JSON.stringify(manifest, null, 2) + '\n') });
return entries;
}
const staticFiles = { '/': ['ui/index.html', 'text/html'], '/app.js': ['ui/app.js', 'text/javascript'], '/i18n.js': ['ui/i18n.js', 'text/javascript'], '/style.css': ['ui/style.css', 'text/css'] };
function send(response, status, value, type = 'application/json', filename) {
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value));
response.writeHead(status, { 'Content-Type': `${type}${type.startsWith('text/') || type === 'application/json' ? '; charset=utf-8' : ''}`, 'Content-Length': bytes.length, ...(filename ? { 'Content-Disposition': `attachment; filename="${filename}"` } : {}) });
response.end(bytes);
}
const server = createServer(async (request, response) => {
response.setHeader('Cache-Control', 'no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
response.setHeader('Referrer-Policy', 'no-referrer');
response.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; media-src blob:; img-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
try {
if (closing) fail(503, 'closing', 'The local app is shutting down.');
if (request.headers.host !== new URL(origin).host || request.headers['x-forwarded-host'] || request.headers.forwarded) fail(403, 'host_denied', 'Open the exact 127.0.0.1 address printed by the server.');
if (request.headers.origin !== undefined && request.headers.origin !== origin) fail(403, 'origin_denied', 'Cross-origin requests are blocked.');
const site = request.headers['sec-fetch-site'];
if (site && !['same-origin', 'none'].includes(site)) fail(403, 'origin_denied', 'Cross-origin requests are blocked.');
// Match the raw path. No URL normalization, filesystem mapping or query parameters.
const path = request.url;
if (Object.hasOwn(staticFiles, path) && request.method === 'GET') {
const [file, type] = staticFiles[path];
return send(response, 200, await readFile(join(root, file)), type);
}
if (path === '/api/session' && request.method === 'GET') {
if (site !== 'same-origin' && request.headers.origin !== origin) fail(403, 'origin_required', 'Session setup requires a same-origin browser request.');
return send(response, 200, { csrfToken: token, configured: Boolean(injectedClient || process.env.CASTREADER_API_KEY), languages: languageNames, cards: cardsInput(await readJson(join(root, 'cards.json'))) });
}
const provided = Buffer.from(request.headers['x-csrf-token'] || '');
if (provided.length !== token.length || !timingSafeEqual(provided, Buffer.from(token))) fail(403, 'csrf_denied', 'Reload the local app to establish a new session.');
if (request.method === 'POST' && request.headers.origin !== origin) fail(403, 'origin_required', 'Writes require an exact Origin header.');
if (path === '/api/catalog' && request.method === 'GET') return send(response, 200, await catalog());
if (path === '/api/batches' && request.method === 'GET') {
const ids = (await readdir(batchesDir)).filter(id => new RegExp(`^${uuid}$`).test(id));
const results = await Promise.allSettled(ids.map(async id => {
const { config } = await batch(id);
// Bad metadata must be isolated before sorting or rendering the list.
if (typeof config.title !== 'string' || !config.title.trim() || config.title.length > 80 || !config.title.isWellFormed()
|| typeof config.createdAt !== 'string' || !Number.isFinite(Date.parse(config.createdAt))) fail(409, 'invalid_batch', 'Batch metadata is incomplete.');
return { id, title: config.title, createdAt: config.createdAt };
}));
const items = results.filter(result => result.status === 'fulfilled').map(result => result.value);
// Preserve every directory; expose only a count, never rejected IDs, paths or errors.
return send(response, 200, { batches: items.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), skippedBatchCount: results.length - items.length });
}
if (path === '/api/batches' && request.method === 'POST') {
const input = await bodyJson(request);
fields(input, ['cards', 'voiceId', 'title']);
const cards = cardsInput(input.cards);
if (typeof input.voiceId !== 'string' || !/^voice_[a-f0-9]{32}$/.test(input.voiceId)) fail(400, 'invalid_voice', 'Choose a ready voice from the server list.');
if (typeof input.title !== 'string' || !input.title.trim() || input.title.length > 80 || !input.title.isWellFormed()) fail(400, 'invalid_title', 'Use a batch title of 1–80 characters.');
const available = await catalog();
const voice = available.voices.find(item => item.id === input.voiceId);
if (!voice) fail(400, 'invalid_voice', 'Choose a ready voice returned for this key.');
if (cards.some(card => !available.languages.includes(card.language) || Array.from(card.text).length > available.maxCharacters)) fail(400, 'model_limit', 'The selected region does not support one of these languages or text lengths.');
let maximum = 0n;
const api = await client();
for (const card of cards) {
const { response: result } = await api.request('/usage/estimate', { method: 'POST', retry: false, body: { model: 'clone-v1', voice_id: voice.id, language: card.language, text: card.text, output_format: 'mp3' } });
const estimate = await result.json();
if (estimate.reservation_created !== false || estimate.balance_sufficient !== true) fail(409, 'estimate_unavailable', 'An input did not pass the non-reserving balance estimate. No generation was submitted.');
if (estimate.billable_characters !== Array.from(card.text).length) fail(502, 'invalid_estimate', 'The API character count differs from the local plan.');
maximum += microUSD(estimate.maximum_charge_usd);
}
const id = randomUUID(), dir = join(batchesDir, id);
const config = { version: 1, id, title: input.title.trim(), createdAt: new Date().toISOString(), voiceId: voice.id, voiceName: voice.name, cards, estimate: { characters: cards.reduce((n, card) => n + Array.from(card.text).length, 0), maximumChargeUSD: usd(maximum), note: 'Before trial credit. Per-row balance checks are snapshots, not a reservation or a guarantee for the whole batch. Trial credit is not counted repeatedly.' } };
await privateDirectory(dir);
await atomicJson(join(dir, 'batch.json'), config);
return send(response, 201, await snapshot(id));
}
const match = batchRoute.exec(path);
if (match) {
const [, id, action, cardId] = match;
if (!action && request.method === 'GET') return send(response, 200, await snapshot(id));
if (action === 'start' && request.method === 'POST') {
const input = await bodyJson(request);
fields(input, ['confirm']);
if (input.confirm !== true) fail(400, 'confirmation_required', 'Explicitly confirm generation or recovery of this saved batch.');
if (active) fail(409, 'batch_running', 'A batch is already running. Wait for it before starting another.');
// Reserve synchronously before any await so rapid clicks cannot race.
const operation = { id, promise: null };
active = operation;
try {
const { dir, config } = await batch(id);
if (await lstat(join(dir, '.running')).catch(() => null)) fail(409, 'batch_locked', 'This batch has a .running lock. Confirm the earlier process stopped before manually removing that exact lock.');
if (await readJson(join(dir, 'execution.json')) && !await readJson(join(dir, 'state.json'))) fail(409, 'checkpoint_missing', 'This started batch has lost its checkpoint. Restore the original files before continuing.');
const api = await client();
progress.set(id, {});
await atomicJson(join(dir, 'execution.json'), { startedAt: new Date().toISOString() });
operation.promise = runCards({ client: api, cards: config.cards, voiceId: config.voiceId, output: dir, waitMs, timeoutMs, log(line) {
const found = /^([a-z0-9-]+): ([a-z_]+)$/.exec(line);
if (found && statuses.has(found[2])) progress.get(id)[found[1]] = found[2];
} }).then(() => atomicJson(join(dir, 'execution.json'), { completedAt: new Date().toISOString() })).catch(error => atomicJson(join(dir, 'execution.json'), { stoppedAt: new Date().toISOString(), error: publicError(error) })).finally(() => { if (active === operation) active = null; progress.delete(id); });
// Observe persistence failures without printing potentially sensitive SDK errors.
operation.promise.catch(() => {});
return send(response, 202, { id, status: 'running' });
} catch (error) { if (active === operation) active = null; throw error; }
}
if (request.method === 'GET' && action?.startsWith('audio/')) {
const { dir, config } = await batch(id);
const card = config.cards.find(row => row.id === cardId);
if (!card) fail(404, 'not_found', 'Clip not found.');
const state = await readJson(join(dir, 'state.json'));
return send(response, 200, await audio(dir, card, state?.rows?.[card.id]), 'audio/mpeg', `${card.id}.mp3`);
}
if (request.method === 'GET' && ['manifest', 'archive'].includes(action)) {
const entries = await exportEntries(id, action === 'archive');
return action === 'archive' ? send(response, 200, zip(entries), 'application/zip', 'flashcards-results.zip') : send(response, 200, entries[0].bytes, 'application/json', 'manifest.json');
}
}
fail(404, 'not_found', 'Route not found.');
} catch (error) {
if (!response.headersSent) send(response, error instanceof AppError ? error.status : 502, { error: publicError(error) });
else response.end();
}
});
server.requestTimeout = 15000;
server.headersTimeout = 10000;
async function releaseLock() { await lock.close(); await unlink(lockPath); }
try {
await new Promise((done, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', done); });
origin = `http://127.0.0.1:${server.address().port}`;
} catch (error) { await releaseLock(); throw error; }
return { server, origin, async close() {
closing = true;
await new Promise(done => server.close(done));
await active?.promise;
await releaseLock();
} };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
if (process.argv.length > 2) { console.error('Use npm start. Optional PORT, FLASHCARDS_DATA_DIR and CASTREADER_API_KEY belong in the server environment.'); process.exitCode = 2; }
else {
const dataDir = process.env.FLASHCARDS_DATA_DIR ? resolve(process.env.FLASHCARDS_DATA_DIR) : join(homedir(), '.castreader-flashcards');
startServer({ port: process.env.PORT === undefined ? 8791 : Number(process.env.PORT), dataDir }).then(app => {
console.log(`Flashcards: ${app.origin}\nPrivate batches: ${join(dataDir, 'batches')}\nGenerate only after reviewing and confirming a plan in the browser.`);
let stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
console.log('Finishing the active batch before shutdown. Keep checkpoints if this process is interrupted.');
app.close().catch(() => { console.error('Shutdown interrupted; preserve the app data and locks for recovery.'); process.exitCode = 1; });
};
process.on('SIGINT', stop); process.on('SIGTERM', stop);
}).catch(error => { console.error(publicError(error).message); process.exitCode = 1; });
}
}
SHA-256: 42b067052495354a93a33c3cd0d31a9d2cc23f94a31c249cc08f41cb95fc45f5