Spaces:
Running
Running
File size: 8,185 Bytes
6c3af4e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type { PreviewServer, ViteDevServer } from "vite";
import {
DICTATION_MODEL_VERSION as MODEL_VERSION,
DICTATION_MODELS_ROUTE_PREFIX as ROUTE_PREFIX,
} from "../shared/dictationModel.ts";
import { isResponseWritable, safeEndResponse } from "./utils/streamUtils.ts";
/**
* Pinned upstream location of the streaming English model (MIT-licensed).
* The browser fetches these from this instance rather than from
* `download.moonshine.ai`, so the page makes no third-party requests; the
* server downloads each file once and caches it on disk.
*/
const UPSTREAM_BASE_URL = `https://download.moonshine.ai/model/tiny-streaming-en/${MODEL_VERSION}`;
/**
* Whitelist of servable files, each with the SHA-256 it must match. A request
* outside this map gets a 404, so the route cannot be turned into an open proxy
* against the upstream host.
*
* The digests are from the download this feature was verified against, which is
* trust-on-first-use: the same guarantee a lockfile integrity hash gives. It
* does not prove the upstream is honest, it pins the artifact that was actually
* reviewed, so a later change under a path segment named `quantized_26_07_30`
* becomes a loud failure instead of a silent swap.
*/
const MODEL_FILES = new Map([
[
"adapter.ort",
"22ecc949e146c49667fda28d102d4e30749a107dc88a396292aa8f277ef1347c",
],
[
"cross_kv.ort",
"143a36667b8d05fd9d04e8c337b7ee121f37ef299aea6b3d82bdb3d3401950b4",
],
[
"decoder_kv.ort",
"8852553f312adb6c9aa4d17418015049b30f412209ee569d336548c0044627de",
],
[
"encoder.ort",
"a8414e1a5dedf9f2093d7680601dd8a9b0433e7020260eafe0e370ead91134ca",
],
[
"frontend.ort",
"271a563251f11e6311949530f8025ed4d345c5d69d4ac1efa74093779927d636",
],
[
"streaming_config.json",
"74fe5ddebd63b17caf59e8a3b18c17547ff7bce1642050edbb1c3962674f8950",
],
[
"tokenizer.bin",
"6884b35fd6377d4c4d32336a0bc152f36b64d1e45b6503683cdc238250a8472d",
],
]);
/** The largest file is ~32 MB, so this bounds a body the upstream mis-sizes. */
const MAX_FILE_BYTES = 64 * 1024 * 1024;
function getModelsDirectory(): string {
return (
process.env.DICTATION_MODELS_DIR ??
path.join(os.tmpdir(), "minisearch-dictation-models")
);
}
/**
* Downloads that are already running, so concurrent first presses of the
* dictation button share one transfer per file instead of racing to write
* the same cache entry.
*/
const downloadsInProgress = new Map<string, Promise<void>>();
/** Reads the body with a ceiling, so a mis-sized upstream cannot exhaust memory. */
async function readCappedBody(
response: Response,
fileName: string,
): Promise<Uint8Array> {
const body = response.body;
if (!body) throw new Error(`Upstream sent no body for ${fileName}`);
const chunks: Uint8Array[] = [];
let total = 0;
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_FILE_BYTES) {
await reader.cancel();
throw new Error(
`${fileName} is larger than the ${MAX_FILE_BYTES} byte cap`,
);
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
async function downloadModelFile(fileName: string): Promise<void> {
// Versioned on disk as well as in the route: a flat cache would serve the
// previous model under a new versioned URL, with `immutable` headers and the
// digest check skipped because the file already exists.
const modelsDirectory = path.join(getModelsDirectory(), MODEL_VERSION);
const filePath = path.join(modelsDirectory, fileName);
const temporaryPath = `${filePath}.${process.pid}.tmp`;
fs.mkdirSync(modelsDirectory, { recursive: true });
const response = await fetch(`${UPSTREAM_BASE_URL}/${fileName}`);
if (!response.ok) {
throw new Error(`Upstream responded ${response.status} for ${fileName}`);
}
const bytes = await readCappedBody(response, fileName);
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
const expected = MODEL_FILES.get(fileName);
if (digest !== expected) {
throw new Error(
`${fileName} does not match the pinned digest (expected ${expected}, got ${digest})`,
);
}
try {
fs.writeFileSync(temporaryPath, bytes);
fs.renameSync(temporaryPath, filePath);
} catch (error) {
// A half-written temporary file is never served, but it would sit in the
// cache directory forever.
fs.rmSync(temporaryPath, { force: true });
throw error;
}
}
/** Serves the file from the disk cache, downloading it once if it is missing. */
async function ensureModelFileOnDisk(fileName: string): Promise<string> {
const filePath = path.join(getModelsDirectory(), MODEL_VERSION, fileName);
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
return filePath;
}
const inFlight = downloadsInProgress.get(fileName);
if (inFlight) {
await inFlight;
return filePath;
}
const download = downloadModelFile(fileName).finally(() => {
downloadsInProgress.delete(fileName);
});
downloadsInProgress.set(fileName, download);
await download;
return filePath;
}
/**
* Serves `/dictation-models/<file>`: the speech-to-text model files, from
* the local disk cache when present and from the pinned upstream URL
* otherwise. Only the whitelisted filenames resolve.
*/
export function dictationModelServerHook<
T extends ViteDevServer | PreviewServer,
>(server: T) {
server.middlewares.use(async (request, response, next) => {
if (!request.url?.startsWith(ROUTE_PREFIX)) return next();
const fileName = new URL(request.url, "http://localhost").pathname.slice(
ROUTE_PREFIX.length,
);
if (!MODEL_FILES.has(fileName)) {
response.statusCode = 404;
safeEndResponse(response, "Unknown dictation model file");
return;
}
try {
const filePath = await ensureModelFileOnDisk(fileName);
if (!isResponseWritable(response)) return;
// Opened before any header is written: a failure here must still be
// answerable as a 502, and a `Content-Length` already on the response
// would leave the client waiting for a body that never comes.
const fileStream = fs.createReadStream(filePath);
await new Promise<void>((resolve, reject) => {
fileStream.once("open", () => resolve());
fileStream.once("error", reject);
});
response.statusCode = 200;
response.setHeader(
"Content-Type",
fileName.endsWith(".json")
? "application/json"
: "application/octet-stream",
);
response.setHeader("Content-Length", String(fs.statSync(filePath).size));
// The upstream files are pinned by an immutable version segment, so
// the browser may keep them for a very long time; the worker's Cache
// API entry is keyed by this URL and only refetched if it is evicted.
response.setHeader(
"Cache-Control",
"public, max-age=31536000, immutable",
);
// Streamed rather than read whole: `decoder_kv.ort` alone is 32 MB, and
// buffering it per request lets a handful of concurrent callers pin that
// much memory each. `pipeline` destroys the file stream when the client
// aborts, which a bare `pipe` would leave open.
await pipeline(fileStream, response);
} catch (error) {
// Once the body has started the headers are gone, so the best that can be
// done is to stop rather than append an error to a truncated file.
if (response.headersSent) {
safeEndResponse(response);
return;
}
response.statusCode = 502;
safeEndResponse(
response,
`Could not serve ${fileName}: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
});
}
|