runonweb 0.0.1

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.
@@ -0,0 +1,280 @@
1
+ import type { ProgressCallback } from '../core/index.ts'
2
+ import { TRANSLATE_CACHE_NAME } from '../core/cache.ts'
3
+ import { PAIRS, RUNTIME_CDN, type PairEntry } from './registry.ts'
4
+ import { WORKER_SOURCE } from './worker.ts'
5
+
6
+ /**
7
+ * Default weights location: the runonweb mirror of Mozilla's Firefox Translations models on the
8
+ * Hugging Face Hub (MPL-2.0). Layout: `<modelPath><from>-<to>/<file>?rev=<revision>`.
9
+ * Self-host by pointing `modelPath` at a folder produced by `scripts/translate-models.mjs`.
10
+ */
11
+ export const DEFAULT_MODEL_PATH = 'https://huggingface.co/runonweb/firefox-translations/resolve/main/'
12
+
13
+ export type BergamotOptions = {
14
+ /** Base URL of the model files. Default: Hugging Face mirror. */
15
+ modelPath?: string
16
+ /** Base URL of `bergamot-translator-worker.{js,wasm}`. Default: jsDelivr. */
17
+ runtimePath?: string
18
+ onProgress?: ProgressCallback
19
+ }
20
+
21
+ export type Route = PairEntry[]
22
+
23
+ function normalize(code: string): string {
24
+ const c = code.trim()
25
+ if (/^zh[-_]hans$/i.test(c) || /^zh[-_]cn$/i.test(c)) return 'zh'
26
+ if (/^zh[-_]hant$/i.test(c) || /^zh[-_]tw$/i.test(c)) return 'zh-Hant'
27
+ return c.toLowerCase().split(/[-_]/)[0] ?? c
28
+ }
29
+
30
+ /** Resolve a language pair to one direct model, or two models pivoting through English. */
31
+ export function resolveRoute(from: string, to: string): Route | null {
32
+ const src = normalize(from)
33
+ const tgt = normalize(to)
34
+ if (src === tgt) return null
35
+ const direct = PAIRS[`${src}-${tgt}`]
36
+ if (direct) return [direct]
37
+ const outbound = PAIRS[`${src}-en`]
38
+ const inbound = PAIRS[`en-${tgt}`]
39
+ if (outbound && inbound) return [outbound, inbound]
40
+ return null
41
+ }
42
+
43
+ /** True when `from → to` can be served, directly or via English. */
44
+ export function hasPair(from: string, to: string): boolean {
45
+ return resolveRoute(from, to) !== null
46
+ }
47
+
48
+ function keyOf(entry: PairEntry): string {
49
+ return `${entry.from}-${entry.to}`
50
+ }
51
+
52
+ /** Relative paths are resolved against the page, since the Blob worker has no usable base URL. */
53
+ function absolute(base: string): string {
54
+ const withSlash = base.endsWith('/') ? base : `${base}/`
55
+ if (/^[a-z]+:/i.test(withSlash)) return withSlash
56
+ const origin = typeof location !== 'undefined' ? location.href : 'http://localhost/'
57
+ return new URL(withSlash, origin).href
58
+ }
59
+
60
+ function joinUrl(base: string, path: string): string {
61
+ return base + path
62
+ }
63
+
64
+ async function openCache(): Promise<Cache | null> {
65
+ try {
66
+ if (typeof caches === 'undefined') return null
67
+ return await caches.open(TRANSLATE_CACHE_NAME)
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ async function readWithProgress(
74
+ res: Response,
75
+ onChunk: (loaded: number, total: number) => void
76
+ ): Promise<ArrayBuffer> {
77
+ const total = Number(res.headers.get('content-length') ?? 0)
78
+ if (!res.body) return res.arrayBuffer()
79
+ const reader = res.body.getReader()
80
+ const chunks: Uint8Array[] = []
81
+ let loaded = 0
82
+ for (;;) {
83
+ const { done, value } = await reader.read()
84
+ if (done) break
85
+ chunks.push(value)
86
+ loaded += value.byteLength
87
+ onChunk(loaded, total)
88
+ }
89
+ const out = new Uint8Array(loaded)
90
+ let offset = 0
91
+ for (const c of chunks) {
92
+ out.set(c, offset)
93
+ offset += c.byteLength
94
+ }
95
+ return out.buffer
96
+ }
97
+
98
+ /**
99
+ * Download one model file, through Cache Storage so the second load is instant and offline.
100
+ */
101
+ async function fetchFile(url: string, file: string, onProgress?: ProgressCallback): Promise<ArrayBuffer> {
102
+ const cache = await openCache()
103
+ const hit = await cache?.match(url)
104
+ if (hit) {
105
+ onProgress?.({ status: 'progress', file, progress: 100 })
106
+ return hit.arrayBuffer()
107
+ }
108
+
109
+ const res = await fetch(url, { credentials: 'omit' })
110
+ if (!res.ok) throw new Error(`Could not download ${file} (HTTP ${res.status}) from ${url}`)
111
+
112
+ const buffer = await readWithProgress(res, (loaded, total) => {
113
+ onProgress?.({ status: 'progress', file, progress: total > 0 ? (loaded / total) * 100 : undefined })
114
+ })
115
+
116
+ if (cache) {
117
+ try {
118
+ await cache.put(
119
+ url,
120
+ new Response(buffer, {
121
+ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.byteLength) },
122
+ })
123
+ )
124
+ } catch {
125
+ // Quota exceeded or private mode: keep going without a cache.
126
+ }
127
+ }
128
+ return buffer
129
+ }
130
+
131
+ type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void }
132
+
133
+ /**
134
+ * Bergamot (Marian NMT compiled to WASM) running in a Web Worker, fed with Firefox Translations
135
+ * models. One instance can hold several language pairs.
136
+ */
137
+ export class BergamotEngine {
138
+ #modelPath: string
139
+ #runtimePath: string
140
+ #onProgress?: ProgressCallback
141
+ #worker: Worker | null = null
142
+ #workerUrl: string | null = null
143
+ #ready: Promise<void> | null = null
144
+ #pending = new Map<number, Pending>()
145
+ #nextId = 1
146
+ #loaded = new Map<string, Promise<void>>()
147
+
148
+ constructor(options: BergamotOptions = {}) {
149
+ this.#modelPath = absolute(options.modelPath ?? DEFAULT_MODEL_PATH)
150
+ this.#runtimePath = absolute(options.runtimePath ?? RUNTIME_CDN)
151
+ this.#onProgress = options.onProgress
152
+ }
153
+
154
+ get modelPath(): string {
155
+ return this.#modelPath
156
+ }
157
+
158
+ #call<T>(name: string, args?: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
159
+ const worker = this.#worker
160
+ if (!worker) return Promise.reject(new Error('Translator has been disposed'))
161
+ const id = this.#nextId++
162
+ return new Promise<T>((resolve, reject) => {
163
+ this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
164
+ worker.postMessage({ id, name, args }, transfer)
165
+ })
166
+ }
167
+
168
+ /** Boot the worker and instantiate the WASM runtime. Idempotent. */
169
+ init(): Promise<void> {
170
+ if (this.#ready) return this.#ready
171
+ if (typeof Worker === 'undefined' || typeof Blob === 'undefined') {
172
+ return Promise.reject(new Error('runonweb/translate needs a browser with Web Workers'))
173
+ }
174
+
175
+ this.#workerUrl = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'text/javascript' }))
176
+ const worker = new Worker(this.#workerUrl)
177
+ this.#worker = worker
178
+
179
+ worker.addEventListener('message', (event: MessageEvent) => {
180
+ const { id, result, error } = event.data as {
181
+ id: number
182
+ result?: unknown
183
+ error?: { name?: string; message?: string; stack?: string }
184
+ }
185
+ const pending = this.#pending.get(id)
186
+ if (!pending) return
187
+ this.#pending.delete(id)
188
+ if (error) {
189
+ const err = new Error(error.message ?? 'Worker error')
190
+ if (error.name) err.name = error.name
191
+ if (error.stack) err.stack = error.stack
192
+ pending.reject(err)
193
+ } else {
194
+ pending.resolve(result)
195
+ }
196
+ })
197
+ worker.addEventListener('error', (event: ErrorEvent) => {
198
+ const err = new Error(event.message || 'Translation worker crashed')
199
+ for (const p of this.#pending.values()) p.reject(err)
200
+ this.#pending.clear()
201
+ })
202
+
203
+ this.#onProgress?.({ status: 'loading', progress: 0 })
204
+ this.#ready = this.#call<boolean>('init', { runtimePath: this.#runtimePath })
205
+ .then(() => undefined)
206
+ .catch((err) => {
207
+ this.#ready = null
208
+ throw err
209
+ })
210
+ return this.#ready
211
+ }
212
+
213
+ #fileUrl(entry: PairEntry, name: string): string {
214
+ return joinUrl(this.#modelPath, `${keyOf(entry)}/${name}?rev=${encodeURIComponent(entry.revision)}`)
215
+ }
216
+
217
+ async #loadEntry(entry: PairEntry): Promise<void> {
218
+ const key = keyOf(entry)
219
+ const existing = this.#loaded.get(key)
220
+ if (existing) return existing
221
+
222
+ const promise = (async () => {
223
+ await this.init()
224
+ const f = entry.files
225
+ const vocabNames = f.vocab ? [f.vocab] : [f.srcvocab!, f.trgvocab!]
226
+ const [model, lex, ...vocabs] = await Promise.all(
227
+ [f.model, f.lex, ...vocabNames].map((name) => fetchFile(this.#fileUrl(entry, name), name, this.#onProgress))
228
+ )
229
+ this.#onProgress?.({ status: 'loading', file: key })
230
+ await this.#call<boolean>('loadModel', { key, modelName: f.model, model, lex, vocabs }, [model, lex, ...vocabs])
231
+ })()
232
+
233
+ this.#loaded.set(key, promise)
234
+ promise.catch(() => this.#loaded.delete(key))
235
+ return promise
236
+ }
237
+
238
+ /** Download (or read from cache) and instantiate every model needed for `from → to`. */
239
+ async load(from: string, to: string): Promise<Route> {
240
+ const route = resolveRoute(from, to)
241
+ if (!route) {
242
+ throw new Error(
243
+ `No Firefox Translations model for ${from} → ${to}. See PAIRS in 'runonweb/translate' for the supported pairs.`
244
+ )
245
+ }
246
+ for (const entry of route) await this.#loadEntry(entry)
247
+ this.#onProgress?.({ status: 'ready', progress: 100 })
248
+ return route
249
+ }
250
+
251
+ /** Translate one or more texts. Models are loaded on demand. */
252
+ async translate(from: string, to: string, texts: string[], options: { html?: boolean } = {}): Promise<string[]> {
253
+ const route = await this.load(from, to)
254
+ return this.#call<string[]>('translate', { route: route.map(keyOf), texts, html: options.html ?? false })
255
+ }
256
+
257
+ /** Free a pair from WASM memory. */
258
+ async unload(from: string, to: string): Promise<void> {
259
+ const route = resolveRoute(from, to) ?? []
260
+ for (const entry of route) {
261
+ const key = keyOf(entry)
262
+ if (!this.#loaded.has(key)) continue
263
+ this.#loaded.delete(key)
264
+ await this.#call('freeModel', { key })
265
+ }
266
+ }
267
+
268
+ dispose(): void {
269
+ const worker = this.#worker
270
+ this.#worker = null
271
+ this.#ready = null
272
+ this.#loaded.clear()
273
+ const err = new Error('Translator disposed')
274
+ for (const p of this.#pending.values()) p.reject(err)
275
+ this.#pending.clear()
276
+ worker?.terminate()
277
+ if (this.#workerUrl) URL.revokeObjectURL(this.#workerUrl)
278
+ this.#workerUrl = null
279
+ }
280
+ }
@@ -0,0 +1,240 @@
1
+ import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
2
+ import { loadPipeline } from '../core/pipeline.ts'
3
+ import { BergamotEngine, DEFAULT_MODEL_PATH, hasPair, resolveRoute, type Route } from './bergamot.ts'
4
+ import { PAIRS, RUNTIME_CDN, RUNTIME_VERSION, type Architecture, type PairEntry } from './registry.ts'
5
+
6
+ export { PAIRS, RUNTIME_CDN, RUNTIME_VERSION, DEFAULT_MODEL_PATH, hasPair, resolveRoute }
7
+ export type { Architecture, PairEntry, Route }
8
+
9
+ /**
10
+ * Default weights: Mozilla's Firefox Translations models (MPL-2.0), the same ones that power
11
+ * Firefox's built-in translation. Marian NMT students compiled to WASM through Bergamot,
12
+ * 17–44 MB per pair, int8. Pairs without a direct model pivot through English.
13
+ *
14
+ * Pass `model` (a Hugging Face id such as `Xenova/m2m100_418M`) to use a Transformers.js
15
+ * translation pipeline instead: one model for 100 languages, at ~630 MB.
16
+ */
17
+ export const MULTILINGUAL_MODEL = 'Xenova/m2m100_418M'
18
+
19
+ export type TranslateOptions = {
20
+ /**
21
+ * Transformers.js model id (`Xenova/m2m100_418M`, `Xenova/opus-mt-en-es`…). Omit to use the
22
+ * Firefox Translations models, which are smaller and faster.
23
+ */
24
+ model?: string
25
+ /**
26
+ * Base URL the Firefox Translations models are served from. Defaults to the runonweb mirror on
27
+ * the Hugging Face Hub. Self-host with `scripts/translate-models.mjs` and pass `/models/translate/`.
28
+ */
29
+ modelPath?: string
30
+ /** Base URL of the Bergamot WASM runtime. Defaults to jsDelivr; self-host next to the models. */
31
+ runtimePath?: string
32
+ device?: Device
33
+ /** Source language code. Default `en`. */
34
+ from?: string
35
+ /** Target language code. Default `es`. */
36
+ to?: string
37
+ onProgress?: ProgressCallback
38
+ }
39
+
40
+ export type TranslateRunOptions = {
41
+ from?: string
42
+ to?: string
43
+ /** Treat the input as HTML: tags are preserved and only text nodes are translated. */
44
+ html?: boolean
45
+ }
46
+
47
+ export type TranslateResult = {
48
+ text: string
49
+ }
50
+
51
+ type Pipe = CallableFunction & { dispose?: () => Promise<void> }
52
+
53
+ /**
54
+ * Neural machine translation in the browser.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { Translator } from 'runonweb/translate'
59
+ *
60
+ * const translator = new Translator({ from: 'en', to: 'es' })
61
+ * await translator.load()
62
+ * const { text } = await translator.translate('Hello world')
63
+ * // "Hola mundo"
64
+ * ```
65
+ */
66
+ export class Translator {
67
+ #model: string | null
68
+ #device: Device
69
+ #from: string
70
+ #to: string
71
+ #onProgress?: ProgressCallback
72
+ #engine: BergamotEngine | null = null
73
+ #pipe: Pipe | null = null
74
+ #loading: Promise<void> | null = null
75
+ #resolvedDevice: 'webgpu' | 'wasm' | null = null
76
+ #modelPath?: string
77
+ #runtimePath?: string
78
+
79
+ constructor(options: TranslateOptions = {}) {
80
+ this.#from = options.from ?? 'en'
81
+ this.#to = options.to ?? 'es'
82
+ this.#model = options.model ?? null
83
+ this.#device = options.device ?? 'auto'
84
+ this.#onProgress = options.onProgress
85
+ this.#modelPath = options.modelPath
86
+ this.#runtimePath = options.runtimePath
87
+ }
88
+
89
+ /** True when the loaded model takes `src_lang` / `tgt_lang` (M2M100, NLLB). */
90
+ get multilingual(): boolean {
91
+ return this.#model !== null && !this.#model.includes('opus-mt')
92
+ }
93
+
94
+ /** Hugging Face id when a Transformers.js model is used, else `firefox-translations`. */
95
+ get model(): string {
96
+ return this.#model ?? 'firefox-translations'
97
+ }
98
+
99
+ get device(): 'webgpu' | 'wasm' | null {
100
+ return this.#resolvedDevice
101
+ }
102
+
103
+ async load(): Promise<void> {
104
+ if (this.#engine || this.#pipe) {
105
+ if (this.#engine) await this.#engine.load(this.#from, this.#to)
106
+ return
107
+ }
108
+ if (this.#loading) return this.#loading
109
+
110
+ this.#loading = this.#model ? this.#loadTransformers(this.#model) : this.#loadBergamot()
111
+ try {
112
+ await this.#loading
113
+ } finally {
114
+ this.#loading = null
115
+ }
116
+ }
117
+
118
+ async #loadBergamot(): Promise<void> {
119
+ if (!hasPair(this.#from, this.#to)) {
120
+ throw new Error(
121
+ `No Firefox Translations model for ${this.#from} → ${this.#to}. See PAIRS in 'runonweb/translate', or pass model: '${MULTILINGUAL_MODEL}'.`
122
+ )
123
+ }
124
+ const engine = new BergamotEngine({
125
+ modelPath: this.#modelPath,
126
+ runtimePath: this.#runtimePath,
127
+ onProgress: this.#onProgress,
128
+ })
129
+ await engine.load(this.#from, this.#to)
130
+ this.#engine = engine
131
+ this.#resolvedDevice = 'wasm'
132
+ }
133
+
134
+ async #loadTransformers(model: string): Promise<void> {
135
+ const { pipe, device } = await loadPipeline({
136
+ task: 'translation',
137
+ model,
138
+ device: this.#device,
139
+ // q8 on WASM. Encoder-decoder models fail on WebGPU in ONNX Runtime Web today.
140
+ dtype: 'q8',
141
+ supportedDevices: ['wasm'],
142
+ onProgress: this.#onProgress,
143
+ })
144
+ this.#pipe = pipe
145
+ this.#resolvedDevice = device
146
+ }
147
+
148
+ /**
149
+ * Translate text. Optional `from` / `to` override constructor defaults (ISO codes like `en`,
150
+ * `es`, `zh-Hant`). With the default models any supported pair can be requested per call;
151
+ * new pairs are downloaded on demand.
152
+ */
153
+ async translate(text: string, options: TranslateRunOptions = {}): Promise<TranslateResult> {
154
+ if (!text.trim()) throw new Error('Text is empty')
155
+ await this.load()
156
+
157
+ const src = options.from ?? this.#from
158
+ const tgt = options.to ?? this.#to
159
+
160
+ this.#onProgress?.({ status: 'translating' })
161
+
162
+ let result: string
163
+ if (this.#engine) {
164
+ const [out] = await this.#engine.translate(src, tgt, [text], { html: options.html })
165
+ result = out ?? ''
166
+ } else {
167
+ result = await this.#translateTransformers(text, src, tgt)
168
+ }
169
+
170
+ this.#onProgress?.({ status: 'done' })
171
+ return { text: result.trim() }
172
+ }
173
+
174
+ async #translateTransformers(text: string, src: string, tgt: string): Promise<string> {
175
+ const pipe = this.#pipe
176
+ if (!pipe) throw new Error('Translator model failed to load')
177
+
178
+ if (!this.multilingual && (src !== this.#from || tgt !== this.#to)) {
179
+ throw new Error(
180
+ `${this.#model} only translates ${this.#from} → ${this.#to}. Create a new Translator for ${src} → ${tgt}, or use model '${MULTILINGUAL_MODEL}'.`
181
+ )
182
+ }
183
+
184
+ const raw = (await pipe(text, this.multilingual ? { src_lang: src, tgt_lang: tgt } : {})) as
185
+ | Array<{ translation_text: string }>
186
+ | { translation_text: string }
187
+ const out = Array.isArray(raw) ? raw[0] : raw
188
+ return out?.translation_text ?? ''
189
+ }
190
+
191
+ dispose(): void {
192
+ const engine = this.#engine
193
+ const pipe = this.#pipe
194
+ this.#engine = null
195
+ this.#pipe = null
196
+ this.#resolvedDevice = null
197
+ engine?.dispose()
198
+ void pipe?.dispose?.()
199
+ }
200
+ }
201
+
202
+ export async function translate(text: string, options?: TranslateOptions): Promise<TranslateResult> {
203
+ const translator = new Translator(options)
204
+ try {
205
+ return await translator.translate(text, { from: options?.from, to: options?.to })
206
+ } finally {
207
+ translator.dispose()
208
+ }
209
+ }
210
+
211
+ export type { ProgressInfo, Device }
212
+
213
+ /** Common language codes. Firefox Translations pairs use BCP 47 (`zh` is Simplified, `zh-Hant` Traditional). */
214
+ export const LANGS = {
215
+ english: 'en',
216
+ spanish: 'es',
217
+ french: 'fr',
218
+ german: 'de',
219
+ portuguese: 'pt',
220
+ italian: 'it',
221
+ dutch: 'nl',
222
+ russian: 'ru',
223
+ chinese: 'zh',
224
+ traditionalChinese: 'zh-Hant',
225
+ japanese: 'ja',
226
+ korean: 'ko',
227
+ arabic: 'ar',
228
+ hindi: 'hi',
229
+ catalan: 'ca',
230
+ galician: 'gl',
231
+ basque: 'eu',
232
+ } as const
233
+
234
+ /** Sorted list of every direct pair, `<from>-<to>`. */
235
+ export const PAIR_KEYS: readonly string[] = Object.keys(PAIRS).sort()
236
+
237
+ /** Languages that can be translated to or from English. */
238
+ export const LANGUAGES: readonly string[] = [
239
+ ...new Set(Object.values(PAIRS).flatMap((p) => [p.from, p.to])),
240
+ ].sort()