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"}`,
      );
    }
  });
}