// M2 · lección 08
Jugar en el navegador
El otro lado del .onnx: un worker que posee la sesión de ONNX Runtime y serializa cada llamada, un contrato que el fichero tiene que cumplir antes de que se le deje jugar, una descarga que mueve la barra de verdad, y WebGPU con su respaldo en WASM bajo aislamiento de origen cruzado.
Qué vas a construir
La mitad de la demo que toca el modelo: el TypeScript de rukh-web que descarga el .onnx de
Hugging Face, lo mete en una sesión de ONNXONNXFormato abierto para describir el grafo de una red y sus pesos, independiente del framework que la entrenó. Es lo que permite entrenar en PyTorch y ejecutar en el navegador con onnxruntime-web, sin Python ni backend. Runtime dentro de
un web workerweb workerHilo aparte del navegador, sin acceso al DOM, que se comunica con la página por mensajes. En la demo de Rukh el worker es el dueño de la sesión de ONNX Runtime: el hilo principal nunca toca el runtime, así que descargar 40 MB de modelo y ejecutar una inferencia no bloquean el tablero., comprueba que el fichero es el que dice ser y contesta los
logits de la siguiente jugada sin bloquear el tablero. Todo el código es del repositorio rukh-web,
en el commit e228cca.
El protocolo: lo que el worker y la página se dicen
// Messages exchanged with `src/workers/decoder.worker.ts`. Both sides import this module, so a// change to a payload breaks the build on the side that did not follow.//// The flow is always the same: `init` (download + session) answers with `progress`* and then// `ready` or `error`; `logits` answers with `logits` or `error`; `dispose` answers with// `disposed`. Every request carries an `id` the answer echoes, so the client can correlate// replies even though the worker already serialises the work.
/** ONNX Runtime Web version; `scripts/copy-assets.mjs` copies that release into `public/ort/`. */export const ORT_VERSION = '1.30.0';
/** Where the self-hosted ORT runtime (`.wasm` + loader) is served from. */export const ORT_BASE = `/ort/${ORT_VERSION}/`;
/** * Cache API bucket for downloaded models. The suffix is bumped whenever what is stored (or how * it is keyed) changes, so an old browser cache is never read with new expectations. */export const MODEL_CACHE = 'rukh-models-v1';rukh-web/src/lib/worker-protocol.ts
Los dos lados importan este módulo, así que un cambio en un mensaje rompe la compilación del lado que no lo siguió. Un protocolo entre dos ficheros que no comparten tipos se desincroniza en silencio.
El runtime se sirve desde public/ort/1.30.0/, que rellena scripts/copy-assets.mjs en el build:
nada se pide a un CDN, porque la política de seguridad de contenidos no lo permitiría. Y la caché
lleva versión (rukh-models-v1) para que, el día que cambie qué se guarda, ninguna entrada vieja se
lea con expectativas nuevas.
export type Backend = 'webgpu' | 'wasm';
export interface InitRequest { type: 'init'; id: number; /** Registry stage id, only used for diagnostics and the cache log. */ stage: string; /** Absolute or same-origin URL of the `.onnx` file. */ url: string; /** Expected size in bytes; used for the progress bar when there is no `content-length`. */ sizeBytes: number; /** Context window the stage was trained with (`DecoderConfig.block`), from the registry. */ block: number; /** Vocabulary size the caller's tokenizer has; the model's output width must match it. */ vocab: number;}
export interface LogitsRequest { type: 'logits'; id: number; /** Token ids of the prompt, already cropped to the context by the caller. */ ids: number[];}
export interface DisposeRequest { type: 'dispose'; id: number;}
export type WorkerRequest = InitRequest | LogitsRequest | DisposeRequest;rukh-web/src/lib/worker-protocol.ts
InitRequest lleva dos números que el fichero no puede dar por sí mismo: block, la ventana de
contexto de la etapa, y vocab, el vocabulario del tokenizador de quien llama. Los dos vienen del
registro. sizeBytes es la estimación que se usa cuando el Hub redirige a su CDN y la respuesta no
trae un content-length utilizable.
export interface ProgressMessage { type: 'progress'; id: number; loaded: number; total: number;}
export interface ReadyMessage { type: 'ready'; id: number; backend: Backend; /** Why WebGPU was not used, when `backend` is `wasm` and a reason is known. */ fallbackReason?: string; loadMs: number; /** The contract the session was checked against; `buildPrompt` crops to `block`. */ block: number; vocab: number;}
export interface LogitsMessage { type: 'logits'; id: number; data: Float32Array; inferMs: number;}
export interface DisposedMessage { type: 'disposed'; id: number;}
export interface ErrorMessage { type: 'error'; id: number; message: string;}
export type WorkerResponse = ProgressMessage | ReadyMessage | LogitsMessage | DisposedMessage | ErrorMessage;rukh-web/src/lib/worker-protocol.ts
const RESPONSE_TYPES = ['progress', 'ready', 'logits', 'disposed', 'error'] as const;
/** * Narrows whatever arrived through `postMessage` to a known response, or `null`. The worker is * ours, but the message channel is not typed at runtime and a stray message must not crash the * page. */export function asResponse(data: unknown): WorkerResponse | null { if (typeof data !== 'object' || data === null) return null; const message = data as { type?: unknown; id?: unknown }; if (typeof message.id !== 'number') return null; const type = message.type; if (typeof type !== 'string') return null; if (!(RESPONSE_TYPES as readonly string[]).includes(type)) return null; return data as WorkerResponse;}rukh-web/src/lib/worker-protocol.ts
El canal de postMessage no está tipado en tiempo de ejecución: cualquier extensión o script del
mismo contexto puede mandar un mensaje. asResponse lo estrecha a un tipo conocido o devuelve
null, la misma disciplina que un extra="forbid" de pydantic en el otro extremo del proyecto.
/** Percentage (0-100) of a progress message; `total` of 0 reports 0 rather than `NaN`. */export function progressPercent(message: Pick<ProgressMessage, 'loaded' | 'total'>): number { if (message.total <= 0) return 0; return Math.min(100, Math.round((message.loaded / message.total) * 100));}
/** Bytes as MB with one decimal, the unit the consent step and the progress bar speak. */export function megabytes(bytes: number): number { return Math.round((bytes / 1_000_000) * 10) / 10;}rukh-web/src/lib/worker-protocol.ts
Un total de cero reporta 0 y no NaN, el porcentaje se acota a 100 y los megabytes son decimales,
la unidad en la que el Hub anuncia los ficheros.
El contrato: qué se le exige a un fichero antes de dejarle jugar
// What the demo demands of a model file before it is allowed to play, and the checks that prove// it. A downloaded `.onnx` is just bytes: nothing stops a stale cache entry, a half-renamed Hub// file or a model trained with another vocabulary from loading cleanly and then answering with a// logits vector that means something else. Every id the sampler reads out of that vector would be// a different move, so the game would look plausible and be nonsense.//// Two things have to hold and both are checked://// * the **width** of the output equals the tokenizer's vocabulary (2030 UCI tokens). ORT Web// does not expose `metadata_props`, so the `rukh_vocab_size` the exporter writes into the file// is unreachable from the browser; what is reachable is the output shape ORT reports// (`outputMetadata`) and, failing that, the length of the first real answer;// * the **context** (`block`) the prompt is cropped to. Nothing in the session says it, so it// comes from the registry entry and travels back to the main thread in `ready`, which is what// keeps `buildPrompt` from hardcoding 200.//// The messages are in Spanish on purpose: they end up in the model panel, in front of a player.El corazón de la lección. Un .onnx descargado son bytes: una caché vieja, un fichero renombrado en
el Hub o un modelo con otro vocabulario se cargan limpiamente y contestan con un vector de logits que
significa otra cosa. Cada id sería una jugada distinta, y la partida parecería plausible siendo un
sinsentido. Ni revienta ni avisa, el peor tipo de fallo.
Se comprueban dos cosas:
- La anchura de la salida tiene que ser el vocabulario del tokenizador (2 030). Como ORT Web no
expone
metadata_props, se mira la forma que declara la salida (outputMetadata) y, si es simbólica, la longitud de la primera respuesta real. - El contexto (
block) viene del registro y vuelve al hilo principal en el mensajeready, para que el constructor del prompt no lleve un 200 escrito a mano.
Los mensajes están en español a propósito, porque acaban delante de un jugador: es el único código del proyecto que no habla inglés.
/** The contract a loaded session is playing under. */export interface ModelContract { /** Context window in tokens (`DecoderConfig.block`); the prompt is cropped to it. */ block: number; /** Width of the logits vector, which must equal the tokenizer's vocabulary size. */ vocab: number;}
/** The part of ORT's `InferenceSession.ValueMetadata` this check needs. */export interface OutputMetadata { name: string; isTensor?: boolean; /** Dimensions; a string entry is a symbolic axis (`batch`, `sequence`). */ shape?: readonly (number | string)[];}
/** The part of `InferenceSession` this check needs, so the tests can hand it a plain object. */export interface SessionLike { readonly outputNames: readonly string[]; readonly outputMetadata?: readonly OutputMetadata[];}
/** Smallest context that can still hold the three header tokens plus one move. */export const MIN_BLOCK = 4;
export const DECLARED_SOURCE = 'la forma declarada en el fichero';export const RUN_SOURCE = 'la primera respuesta del modelo';SessionLike tipa solo la parte de ORT que se usa, así que los tests le pasan un objeto plano en vez
de una sesión real: un test de milisegundos en vez de uno que descarga 28 MB de WASM. MIN_BLOCK = 4
son los tres tokens de cabecera más una jugada.
/** * The fixed last dimension of the model's only output, or `null` when ORT reports it as symbolic * (or reports nothing at all, which is what the WASM backend does for some graphs). */export function declaredVocab(session: SessionLike): number | null { const name = session.outputNames[0]; const meta = session.outputMetadata?.find((entry) => entry.name === name); const shape = meta?.shape; if (!shape || shape.length === 0) return null; const last = shape[shape.length - 1]; return typeof last === 'number' && Number.isInteger(last) && last > 0 ? last : null;}Una forma simbólica trae una cadena en la última dimensión, y el backend de WASM a veces no reporta
forma. En esos casos devuelve null, que significa «todavía no se sabe», no «está mal».
/** Throws with a message a player can read when the logits are not the tokenizer's width. */export function assertVocab(width: number, expected: number, source: string): void { if (width === expected) return; throw new Error( `El modelo no encaja con el tokenizador: ${source} da ${width} valores y el vocabulario UCI ` + `tiene ${expected}. No se juega con este fichero; borra los modelos descargados y vuelve a ` + `intentarlo.`, );}El mensaje dice de dónde sale el número que no cuadra, los dos números y qué hacer. Un
Error('vocab mismatch') habría sido igual de correcto y completamente inútil.
/** * The contract of a freshly created session: `block` from the registry (nothing in the session * knows it) and `vocab` from the declared output shape when ORT gives a fixed one, checked against * the tokenizer right away. When the shape is symbolic the width is still unknown here and * `assertVocab` runs on the first `run` instead — see `decoder.worker.ts`. */export function readContract( session: SessionLike, expectedVocab: number, block: number,): ModelContract { if (!Number.isInteger(block) || block < MIN_BLOCK) { throw new Error(`El contexto declarado para esta etapa (${block}) no es utilizable.`); } const declared = declaredVocab(session); if (declared !== null) assertVocab(declared, expectedVocab, DECLARED_SOURCE); return { block, vocab: declared ?? expectedVocab };}La anchura se comprueba si se puede; si la forma era simbólica, la comprobación ocurre en la primera respuesta, dentro del worker. Dos caminos para una sola garantía, porque ninguno está disponible siempre.
La descarga: que la barra se mueva
La descarga con barra de progreso es ingeniería web pura: si ya has escrito un fetch con streaming,
puedes saltarla. Si la saltas, tráela, dentro de rukh-web: git checkout e228cca -- src/lib/download.ts; el worker la importa y sin ella la demo no compila.
// Fontaneríadownload.ts: descargar el modelo por trozos, con progreso y caché
// Downloading the model bytes, with the progress bar actually moving while they arrive.//// The obvious version — `await cache.put(url, response.clone())` before reading the body — does// not work: `put` only resolves once the clone's body has been consumed, and a cloned body is fed// by the same underlying stream, so the browser buffers the whole file (twice: the clone and the// copy the cache keeps) before the first progress message is ever sent. On an 80 MB fp16 export// that is a minute of a bar sitting at zero. So the body is streamed first and the cache entry is// written from the bytes afterwards, which also means a download that fails half-way leaves// nothing behind.//// Everything the browser provides is injected, so the whole thing is testable without a browser.La versión obvia, await cache.put(url, response.clone()) antes de leer el cuerpo, no funciona:
put solo resuelve cuando el clon se ha consumido, y el clon comparte flujo con el original, así que
el navegador almacena el fichero entero antes del primer mensaje de progreso. Con 80 MB de fp16 es
un minuto de barra parada. Por eso el cuerpo se transmite primero y la caché se escribe después, y de
regalo una descarga que falla a mitad no deja nada en la caché.
/** What `downloadModel` needs from its environment. */export interface DownloadDeps { fetch: typeof globalThis.fetch; /** `caches`, or undefined where the Cache API is not available (the model is just refetched). */ caches?: CacheStorage; /** Name of the Cache API bucket the weights are stored in. */ cacheName: string; /** Called after every chunk; `total` is the best estimate available at that moment. */ onProgress: (loaded: number, total: number) => void;}/** Joins the chunks a stream produced into one buffer. */function concat(chunks: readonly Uint8Array[], length: number): Uint8Array { const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes;}Todo lo que aporta el navegador se inyecta (fetch, caches, el callback de progreso), así que la
función se prueba sin navegador.
/** * The model bytes, from the Cache API when they are already there and from the network otherwise, * reporting progress as the body streams in. `sizeBytes` is the registry's estimate and is only * used when the response has no usable `content-length` (a Hub redirect to a CDN sometimes omits * it); a `loaded` that overshoots it wins, so the bar never reports more than 100 %. */export async function downloadModel( url: string, sizeBytes: number, deps: DownloadDeps,): Promise<Uint8Array> { const cache = await deps.caches?.open(deps.cacheName).catch(() => null); const cached = await cache?.match(url).catch(() => undefined); const response = cached ?? (await deps.fetch(url, { mode: 'cors', credentials: 'omit' })); if (!response.ok) { throw new Error(`no se pudo descargar el modelo (${response.status})`); }
const declared = Number(response.headers.get('content-length') ?? '0'); const total = declared > 0 ? declared : sizeBytes;
const body = response.body; if (!body) { // No stream (an old browser, or a fake in a test): one shot, one progress message. const buffer = new Uint8Array(await response.arrayBuffer()); deps.onProgress(buffer.byteLength, buffer.byteLength); return buffer; }Un almacenamiento bloqueado (una ventana privada) no puede impedir jugar, solo obliga a volver a
descargar. Y sin body se lee de golpe y se envía un solo mensaje de progreso: la barra salta de 0 a
100 en vez de mentir.
const reader = body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.byteLength; deps.onProgress(loaded, Math.max(total, loaded)); } const bytes = concat(chunks, loaded); // The chunks are now a second copy of the whole model: drop them before anyone awaits again. chunks.length = 0; deps.onProgress(loaded, loaded);
// Only now, with the whole file in hand and the bar at 100 %, is the cache written. if (!cached && cache) { // `bytes.buffer` is the freshly allocated buffer `concat` made, so handing it over is a // move, not a copy; `Response` reads it synchronously here. await cache .put( url, new Response(bytes.buffer as ArrayBuffer, { headers: { 'content-type': 'application/octet-stream', 'content-length': String(bytes.byteLength), }, }), ) .catch(() => undefined); } return bytes;}Tres detalles del bucle:
Math.max(total, loaded)hace crecer el total si la descarga pasa de la estimación.chunks.length = 0en cuanto se unen los trozos: en ese instante hay dos copias del modelo en memoria, y en un móvil soltar una antes del siguienteawaites la diferencia entre jugar y que el navegador mate la pestaña.- La caché se escribe al final, cediendo el búfer en vez de copiarlo; y no poder guardar es un inconveniente, no un fallo.
El worker: quien posee la sesión
// Dedicated worker that owns the ONNX Runtime session: it downloads the model (streaming the// progress back), keeps it in the Cache API and answers `logits` requests with the last step's// logits. The main thread never touches ORT, so a 28 MB WASM runtime and a 40 MB model never// block the board.//// Two serialisation rules come from ORT itself and are not negotiable: sessions are created one// at a time and `run` calls never overlap (the JSEP/asyncify build cannot re-enter an async// call). Both go through `serial`, a single promise chain — the download included, so two `init`// messages in flight cannot end up fetching twice and racing to install their session.//// The file is never trusted: `readContract` checks the declared output width against the// tokenizer's vocabulary as soon as the session exists, and `assertVocab` checks the real width// of every answer. See `src/lib/contract.ts` for why.rukh-web/src/workers/decoder.worker.ts
El worker posee la sesión: el hilo principal nunca toca ORT, así que ni el runtime ni el modelo
bloquean el tablero. ORT impone además que las sesiones se creen de una en una y que las llamadas a
run no se solapen. Y el contrato se comprueba al crear la sesión y en cada respuesta.
import * as ort from 'onnxruntime-web/webgpu';import { RUN_SOURCE, assertVocab, readContract, type ModelContract } from '../lib/contract';import { downloadModel } from '../lib/download';import { MODEL_CACHE, ORT_BASE, type Backend, type WorkerRequest, type WorkerResponse,} from '../lib/worker-protocol';
/** The worker global, typed with just what this file uses (avoids pulling in the webworker lib). */interface WorkerScope { postMessage(message: WorkerResponse, transfer?: Transferable[]): void; addEventListener(type: 'message', listener: (event: MessageEvent) => void): void;}
const ctx = self as unknown as WorkerScope;rukh-web/src/workers/decoder.worker.ts
WorkerScope tipa solo lo que se usa, como SessionLike.
// Self-hosted runtime: `public/ort/<version>/` is filled by `scripts/copy-assets.mjs` so the// page never reaches a CDN (the CSP would not allow it either).ort.env.wasm.wasmPaths = ORT_BASE;// One thread, always. WASM is only the fallback here (WebGPU is the fast path and this decoder// is 40 MB), and asking for more threads under cross-origin isolation makes ORT reach for the// threaded/proxy artefacts that `scripts/copy-assets.mjs` deliberately does not publish. Keeping// it at one means the runtime needs exactly the two files we do publish, isolated or not.ort.env.wasm.numThreads = 1;// No proxy worker either: it would be a third artefact (`ort-wasm-proxy-worker`) and this code// already runs off the main thread, which is the only thing the proxy buys.ort.env.wasm.proxy = false;ort.env.logLevel = 'error';rukh-web/src/workers/decoder.worker.ts
numThreads = 1 siempre. WASMWASMWebAssembly: formato binario que el navegador ejecuta a velocidad cercana a la nativa. En Rukh es el respaldo de WebGPU y corre el grafo int8 (unos 43 MB frente a los 79 del fp16), que es lo que hace que la demo cargue en un móvil; el runtime se sirve desde el propio origen, nunca desde un CDN. es solo el respaldo
de WebGPUWebGPUAPI del navegador que da acceso a la GPU para cómputo general, y el camino rápido de la demo de Rukh: onnxruntime-web ejecuta con ella el grafo fp16. Cuando no hay adaptador —o cuando crear la sesión sobre él falla— la demo cae a WASM y publica el motivo debajo de la insignia del backend., y pedir más hilos hace que ORT busque artefactos que
copy-assets.mjs deliberadamente no publica. proxy = false por lo mismo: lo único que compra el
proxy, salir del hilo principal, ya lo da el worker.
let session: ort.InferenceSession | null = null;/** Block and vocabulary the live session was accepted under; null while there is no session. */let contract: ModelContract | null = null;
/** The single chain every ORT call is queued on: no two sessions or runs are ever in flight. */let chain: Promise<unknown> = Promise.resolve();
function serial<T>(work: () => Promise<T>): Promise<T> { const next = chain.then(work, work); chain = next.then( () => undefined, () => undefined, ); return next;}
function reply(message: WorkerResponse, transfer?: Transferable[]): void { ctx.postMessage(message, transfer);}rukh-web/src/workers/decoder.worker.ts
serial encola cada trabajo detrás del anterior; chain.then(work, work) hace que un fallo no rompa
la cadena, y el chain guardado se queda en undefined para no propagar el rechazo a quien no lo
pidió.
/** `downloadModel` wired to this worker's globals, reporting progress back to the main thread. */function fetchModel(id: number, url: string, sizeBytes: number): Promise<Uint8Array> { return downloadModel(url, sizeBytes, { fetch: globalThis.fetch.bind(globalThis), caches: typeof caches === 'undefined' ? undefined : caches, cacheName: MODEL_CACHE, onProgress: (loaded, total) => reply({ type: 'progress', id, loaded, total }), });}rukh-web/src/workers/decoder.worker.ts
/** The WebGPU adapter, or the reason there is none. Never throws. */async function webgpuAdapter(): Promise<{ adapter: unknown } | { reason: string }> { const gpu = (globalThis as { navigator?: { gpu?: { requestAdapter(): Promise<unknown> } } }) .navigator?.gpu; if (!gpu) return { reason: 'este navegador no expone WebGPU' }; try { const adapter = await gpu.requestAdapter(); if (!adapter) return { reason: 'WebGPU no ha ofrecido ningún adaptador' }; return { adapter }; } catch (cause) { return { reason: `WebGPU ha fallado al pedir el adaptador: ${describe(cause)}` }; }}
function describe(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause);}rukh-web/src/workers/decoder.worker.ts
La detección de WebGPU nunca lanza: devuelve el adaptador o el motivo de que no lo haya, y ese motivo se imprime bajo la insignia del panel. Una partida lenta en WASM nunca es un misterio.
/** * WebGPU whenever `requestAdapter()` hands out an adapter; WASM only when it does not, or when * creating the session on WebGPU throws. Both fallbacks carry the reason, which the panel shows * under the badge so a slow game on WASM is never a mystery. */async function createSession( bytes: Uint8Array,): Promise<{ session: ort.InferenceSession; backend: Backend; reason: string | null }> { const options: ort.InferenceSession.SessionOptions = { graphOptimizationLevel: 'all', executionMode: 'sequential', }; const probe = await webgpuAdapter(); let reason = 'reason' in probe ? probe.reason : null; if (!reason) { try { const created = await ort.InferenceSession.create(bytes, { ...options, executionProviders: ['webgpu'], }); return { session: created, backend: 'webgpu', reason: null }; } catch (cause) { // An adapter that cannot compile the graph is a normal outcome, not a bug: say so and // carry on with WASM. reason = `WebGPU no ha podido crear la sesión: ${describe(cause)}`; } } const created = await ort.InferenceSession.create(bytes, { ...options, executionProviders: ['wasm'], }); return { session: created, backend: 'wasm', reason };}rukh-web/src/workers/decoder.worker.ts
WASM cuando no hay adaptador, o cuando crear la sesión sobre WebGPU lanza: hay adaptadores que no compilan el grafo, y lo correcto es decirlo y seguir.
/** * Downloads the weights and hands them to ORT. The buffer is a local of this frame on purpose: * when it returns, the only copy of the model still alive is the one inside the session, instead * of 80 MB sitting next to it for as long as the worker lives. */async function loadSession(request: Extract<WorkerRequest, { type: 'init' }>) { const bytes = await fetchModel(request.id, request.url, request.sizeBytes); return createSession(bytes);}rukh-web/src/workers/decoder.worker.ts
El búfer es una variable local a propósito: cuando la función vuelve, la única copia viva del modelo es la de la sesión, en vez de 80 MB sentados al lado mientras dure el worker.
async function init(request: Extract<WorkerRequest, { type: 'init' }>): Promise<void> { const started = performance.now(); // The download runs inside the chain too: outside it, a second `init` would start its own fetch // while the first was still tearing the old session down, and both would race to install one. const ready = await serial(async () => { await session?.release(); session = null; contract = null; const created = await loadSession(request); const checked = readContract(created.session, request.vocab, request.block); session = created.session; contract = checked; return { backend: created.backend, reason: created.reason, contract: checked }; }); reply({ type: 'ready', id: request.id, backend: ready.backend, fallbackReason: ready.reason ?? undefined, loadMs: Math.round(performance.now() - started), block: ready.contract.block, vocab: ready.contract.vocab, });}rukh-web/src/workers/decoder.worker.ts
La descarga va dentro de la cadena. Fuera, un segundo init empezaría su descarga mientras el
primero liberaba la sesión vieja, y los dos correrían a instalar la suya. El orden importa: se libera
la sesión anterior, se carga la nueva, se comprueba el contrato y solo entonces se asigna. Si el
contrato falla, el worker se queda sin sesión en vez de con una que no cumple.
async function logits(request: Extract<WorkerRequest, { type: 'logits' }>): Promise<void> { const data = await serial(async () => { const current = session; const live = contract; if (!current || !live) throw new Error('el modelo todavía no está cargado'); const ids = BigInt64Array.from(request.ids, (id) => BigInt(id)); const feeds: Record<string, ort.Tensor> = { [current.inputNames[0]]: new ort.Tensor('int64', ids, [1, request.ids.length]), }; const started = performance.now(); const output = await current.run(feeds); const inferMs = performance.now() - started; const tensor = output[current.outputNames[0]]; const raw = tensor.data; if (!(raw instanceof Float32Array)) { throw new Error(`la salida del modelo no es float32 (${tensor.type})`); } // The declared shape is often symbolic, so the real width is only knowable here. A model // whose logits are not the tokenizer's vocabulary would map every id to a different move. assertVocab(raw.length, live.vocab, RUN_SOURCE); // Copy out of the ORT arena: the tensor's buffer may be reused by the next run. return { values: raw.slice(), inferMs }; }); reply({ type: 'logits', id: request.id, data: data.values, inferMs: data.inferMs }, [ data.values.buffer, ]);}rukh-web/src/workers/decoder.worker.ts
El modelo espera int64, que en JavaScript son BigInt, convertidos uno a uno. Los nombres de
entrada y salida se leen de la sesión, así que un cambio de exportador no rompe la demo. Y tres
cosas separan esto de un ejemplo:
- Se comprueba que la salida es
Float32Array; un fp16 mal convertido daría basura silenciosa. assertVocabcomprueba la anchura real en cada respuesta, el segundo camino del contrato.raw.slice()copia fuera de la arena de ORT, que puede reutilizar el búfer en la siguiente ejecución: devolverlo tal cual produce jugadas raras a partir de la segunda.
El postMessage transfiere el búfer en vez de copiarlo.
async function dispose(request: Extract<WorkerRequest, { type: 'dispose' }>): Promise<void> { await serial(async () => { await session?.release(); session = null; contract = null; }); reply({ type: 'disposed', id: request.id });}
ctx.addEventListener('message', (event: MessageEvent) => { const request = event.data as WorkerRequest; const run = async () => { switch (request.type) { case 'init': return init(request); case 'logits': return logits(request); case 'dispose': return dispose(request); default: return undefined; } }; void run().catch((error: unknown) => { reply({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error), }); });});rukh-web/src/workers/decoder.worker.ts
El despachador convierte cualquier excepción en un mensaje error con el mismo id: toda petición
recibe una respuesta, y ninguna promesa se queda colgada en el hilo principal.
El cliente: la mitad del hilo principal
// Main-thread side of the decoder worker. The `new Worker(new URL(...), { type: 'module' })`// literal lives here, at the call site, because that is the only shape Vite recognises to emit// the worker as its own chunk (`tests/worker-chunk.test.ts` guards it).import { asResponse, type Backend, type InitRequest, type ProgressMessage, type ReadyMessage, type WorkerRequest,} from '../lib/worker-protocol';rukh-web/src/workers/decoder-client.ts
El new Worker(new URL('./decoder.worker.ts', import.meta.url), { type: 'module' }) tiene que estar
literal en el sitio donde se usa: es la única forma que Vite reconoce para emitir el worker como su
propio fragmento. Extraerlo a una función auxiliar hace que la demo falle en producción y no en
desarrollo, y tests/worker-chunk.test.ts lo vigila.
export interface LoadReport { backend: Backend; /** Why WebGPU was not used, when it was not. */ fallbackReason?: string; loadMs: number; /** Context window the session was accepted under; `buildPrompt` crops the prompt to it. */ block: number; /** Width of the logits the model answers with, already checked against the tokenizer. */ vocab: number;}
/** What `init` needs to know about a stage; the worker's `id` is added by `send`. */export type LoadRequest = Omit<InitRequest, 'type' | 'id'>;
export interface LogitsReport { data: Float32Array; inferMs: number;}
export interface Decoder { /** Downloads (or reads from the cache) the model, creates the session and checks its contract. */ init(request: LoadRequest, onProgress?: (progress: ProgressMessage) => void): Promise<LoadReport>; /** Logits of the last step for a prompt already cropped to the context window. */ logits(ids: number[]): Promise<LogitsReport>; /** Releases the session and terminates the worker; the handle is unusable afterwards. */ dispose(): Promise<void>;}rukh-web/src/workers/decoder-client.ts
/** `Omit` over a union collapses it, so the distribution is written out explicitly. */type Unidentified<T> = T extends WorkerRequest ? Omit<T, 'id'> : never;
interface Pending { resolve: (value: never) => void; reject: (error: Error) => void; onProgress?: (progress: ProgressMessage) => void;}rukh-web/src/workers/decoder-client.ts
Omit sobre una unión la colapsa a los campos comunes, así que Unidentified<T> distribuye a mano
con un condicional.
export function createDecoder(): Decoder { const worker = new Worker(new URL('./decoder.worker.ts', import.meta.url), { type: 'module' }); const pending = new Map<number, Pending>(); let nextId = 1; let terminated = false;
const fail = (error: Error) => { for (const entry of pending.values()) entry.reject(error); pending.clear(); };
worker.addEventListener('message', (event: MessageEvent) => { const message = asResponse(event.data); if (!message) return; const entry = pending.get(message.id); if (!entry) return; if (message.type === 'progress') { entry.onProgress?.(message); return; } pending.delete(message.id); if (message.type === 'error') { entry.reject(new Error(message.message)); return; } (entry.resolve as (value: unknown) => void)(message); });
worker.addEventListener('error', (event: ErrorEvent) => { fail(new Error(event.message || 'el worker del modelo ha fallado')); });
function send<T>( request: Unidentified<WorkerRequest>, onProgress?: (progress: ProgressMessage) => void, ): Promise<T> { if (terminated) return Promise.reject(new Error('el worker del modelo ya se ha cerrado')); const id = nextId++; return new Promise<T>((resolve, reject) => { pending.set(id, { resolve: resolve as (value: never) => void, reject, onProgress }); worker.postMessage({ ...request, id } as WorkerRequest); }); }rukh-web/src/workers/decoder-client.ts
Un Map de peticiones pendientes por id. progress invoca el callback sin resolver la promesa, y
un error del worker rechaza todas las pendientes, porque un worker caído no va a contestar ninguna.
return { async init(request, onProgress) { const ready = await send<ReadyMessage>({ type: 'init', ...request }, onProgress); return { backend: ready.backend, fallbackReason: ready.fallbackReason, loadMs: ready.loadMs, block: ready.block, vocab: ready.vocab, }; }, async logits(ids) { const answer = await send<{ data: Float32Array; inferMs: number }>({ type: 'logits', ids }); return { data: answer.data, inferMs: answer.inferMs }; }, async dispose() { if (terminated) return; try { await send<unknown>({ type: 'dispose' }); } catch { /* the worker may already be gone; terminating below is enough */ } terminated = true; fail(new Error('el worker del modelo se ha cerrado')); worker.terminate(); }, };}rukh-web/src/workers/decoder-client.ts
dispose pide al worker que libere, marca el handle como terminado, rechaza lo pendiente y después
termina el worker. Al revés, la sesión de ORT quedaría sin liberar.
El registro: de dónde sale cada fichero
El registro es una tabla de etapas y URLs, y se puede saltar hasta la decisión que importa, en el
párrafo de debajo. Si lo saltas, tráelo, dentro de rukh-web: git checkout e228cca -- src/lib/registry.ts; la app, el panel y query.ts lo importan.
// Fontaneríaregistry.ts: las etapas publicadas, sus URLs del Hub y la elección de fichero por dispositivo
export type StageKind = 'mock' | 'onnx';
export interface Stage { id: string; label: string; kind: StageKind; /** Download size in MB, shown in the consent step. 0 for stages that download nothing. */ sizeMb: number; /** Hugging Face repo id, for `kind: 'onnx'` stages served from the Hub. */ repo?: string; /** Path of the ONNX file inside the repo. */ file?: string; /** Same-origin URL that replaces `repo`/`file` (only the toy model used by the E2E suite). */ url?: string; /** True once the stage is trained with the Elo conditioning tokens (M4). */ eloConditioned?: boolean; /** * Context window the stage was trained with (`DecoderConfig.block`). It lives here because ORT * Web does not expose the `rukh_block` metadata the exporter writes into the file, so the only * place the browser can learn it is the registry; `DEFAULT_BLOCK` when a stage omits it. */ block?: number;}
/** `DecoderConfig.block` of every model published so far. */export const DEFAULT_BLOCK = 200;
/** The context window a stage plays with. */export function stageBlock(stage: Stage): number { return stage.block ?? DEFAULT_BLOCK;}block vive en el registro porque ORT Web no expone el rukh_block del exportador.
/** * Download sizes in MB. **Provisional**: they are the plan's estimates for the fp16 and int8 * exports and the controller updates them here, in this one place, after the real export * (`rukh export ... --fp16 --int8`) reports the file sizes. They only drive the consent copy and * the progress bar fallback, never the download itself. */export const STAGE_SIZE_MB = { 'tiny-int8': 6, 'small-fp16': 80, 'small-int8': 40,} as const;
/** Licence of every published Rukh model; shown before anything is downloaded. */export const MODEL_LICENSE = 'Apache-2.0';Los tamaños son estimaciones del plan y lo dicen. La lección anterior midió 78,8 MB para el fp16 y
43,5 para el int8 frente a 80 y 40 estimados; por eso download.ts acota el porcentaje a 100.
export const STAGES: Stage[] = [ { id: 'mock', label: 'Primera jugada legal', kind: 'mock', sizeMb: 0 }, { id: 'tiny-int8', label: 'Rukh tiny (int8)', kind: 'onnx', repo: 'chorcat/rukh-tiny', file: 'onnx/model-int8.onnx', sizeMb: STAGE_SIZE_MB['tiny-int8'], block: DEFAULT_BLOCK, }, { id: 'small-fp16', label: 'Rukh small (fp16)', kind: 'onnx', repo: 'chorcat/rukh-small', file: 'onnx/model-fp16.onnx', sizeMb: STAGE_SIZE_MB['small-fp16'], block: DEFAULT_BLOCK, }, { id: 'small-int8', label: 'Rukh small (int8)', kind: 'onnx', repo: 'chorcat/rukh-small', file: 'onnx/model-int8.onnx', sizeMb: STAGE_SIZE_MB['small-int8'], block: DEFAULT_BLOCK, },];/** * Toy decoder committed under `public/test/`: one layer, `d_model=8`, and the real contract * (`idx (B, T)` int64 -> `logits (B, 2030)` with `block` 200), exported by the very function that * writes the published models (`rukh.export.export_onnx`, dynamo exporter, `rukh_*` metadata). It * is not in `STAGES` (nothing offers it in the selector); only `?stage=test` reaches it, which is * how the E2E suite exercises the real worker path — contract check included — without * downloading 40 MB from the Hub. */export const TEST_STAGE: Stage = { id: 'test', label: 'ONNX de juguete (pruebas)', kind: 'onnx', sizeMb: 0.2, url: '/test/toy-decoder.onnx', block: DEFAULT_BLOCK,};Una idea que vale copiar: un decoder de una capa y d_model=8 con el contrato de verdad, exportado
por la misma función que los modelos publicados. Pesa 200 KB, está versionado en public/test/ y
solo se llega a él con ?stage=test. Así la suite E2E ejercita el camino real del worker sin
descargar 40 MB del Hub en cada ejecución de la CI.
export function findStage(id: string): Stage | undefined { if (id === TEST_STAGE.id) return TEST_STAGE; return STAGES.find((stage) => stage.id === id);}
/** Hub URL of a stage, or its own `url` when it is served from this origin. */export function modelUrl(stage: Stage): string { if (stage.url) return stage.url; if (!stage.repo || !stage.file) { throw new Error(`stage ${stage.id} has no model file`); } return `https://huggingface.co/${stage.repo}/resolve/main/${stage.file}`;}
/** The stages the selector offers: the registry plus `current` when it is not part of it. */export function selectableStages(current: string): Stage[] { const extra = STAGES.some((stage) => stage.id === current) ? undefined : findStage(current); return extra ? [...STAGES, extra] : STAGES;}export interface Conditions { /** `navigator.connection?.saveData`. */ saveData?: boolean; /** True on a phone-sized or coarse-pointer device. */ mobile?: boolean;}
/** * Stage selected when the page is opened without `?stage=`: the 40 MB int8 export on mobile or * with data saver on, the 80 MB fp16 one elsewhere. */export function defaultStageId(conditions: Conditions = {}): string { return conditions.saveData || conditions.mobile ? 'small-int8' : 'small-fp16';}
/** `defaultStageId` reading the browser it runs in; falls back to the desktop choice. */export function detectConditions(): Conditions { if (typeof navigator === 'undefined') return {}; const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection; const mobile = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 820px), (pointer: coarse)').matches : false; return { saveData: connection?.saveData === true, mobile };}La etapa por defecto es el int8 en un móvil o con ahorro de datos, y el fp16 en el resto. Es la
decisión que ahorra 38 MB y cambia el 4,6 % de las jugadas: el código la toma para que la demo
funcione, y el curso tiene que decir lo que cuesta. El typeof navigator existe porque el módulo
también se importa al renderizar en el servidor.
COOP, COEP y por qué el WASM multihilo no llega gratis
# Included in every location: nginx does not inherit add_header into a block that declares# its own add_header, so each location pulls the full set from here.## The page-level CSP (script/style hashes) lives in the <meta> tag generated by Astro;# frame-ancestors cannot be set from <meta>, so it is added as a header.add_header Content-Security-Policy "frame-ancestors 'none'" always;add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;add_header X-Content-Type-Options "nosniff" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;# Cross-origin isolation: required for multi-threaded WASM (onnxruntime-web, P2).add_header Cross-Origin-Opener-Policy "same-origin" always;add_header Cross-Origin-Embedder-Policy "require-corp" always;rukh-web/nginx/security-headers.conf
Las dos últimas, Cross-Origin-Opener-Policy y Cross-Origin-Embedder-Policy, son
el aislamiento de origen cruzadoaislamiento de origen cruzadoEstado que el navegador concede a una página que envía Cross-Origin-Opener-Policy: same-origin y Cross-Origin-Embedder-Policy: require-corp, y que es el requisito para usar SharedArrayBuffer y, con él, WASM multihilo. La demo de Rukh lo declara en nginx aunque después use un solo hilo: tenerlo puesto significa que el día que el respaldo de WASM sea el camino principal no hay que cambiar el despliegue. que el navegador exige
para dar SharedArrayBuffer, lo que necesita el WASM multihilo. La demo lo pide y luego usa un solo
hilo: el aislamiento no obliga a usar hilos, y tenerlo puesto evita tocar el despliegue el día que
el respaldo de WASM sea el camino principal.
Dos trampas de servidor que conviene saberse. nginx no hereda add_header en un bloque que declara
el suyo, así que cada location vuelve a incluir el fichero: si no, la cabecera está en la portada
y falta en /ort/, justo donde hacía falta. Y el mime.types de nginx no conoce .mjs: el cargador
de ORT salía como application/octet-stream, nosniff impedía evaluarlo y la sesión nunca se
creaba. Un fallo que solo ocurre en producción, causado por una tabla de tipos MIME.
// Ejercicio 01¿Qué pasa si el contrato no comprueba nada?
Imagina que readContract no comprueba la anchura y que assertVocab desaparece del run. Cargas
por error un .onnx con vocabulario 1 968 (el de M1 sin los tokens de Elo) en una demo cuyo
tokenizador tiene 2 030. ¿Qué ve el jugador? ¿Por qué es peor que un error?
// SoluciónVer la solución
El modelo contesta 1 968 logits. La máscara del navegador usa ids de un vocabulario de 2 030, así que 62 índices caen fuera del vector y, peor, los 1 968 que sí están significan otra cosa: el desplazamiento de los tokens especiales mueve todas las jugadas. El jugador ve jugadas legales (la máscara las fuerza) elegidas al azar.
Es peor que un error porque parece que funciona. Nadie va a abrir la consola: van a concluir que el modelo juega fatal. El contrato convierte «juega mal» en «este fichero no encaja», y ese es todo su valor.
Qué has aprendido
Cómo se lleva un .onnx de 79 MB a una pestaña, y lo que hay que hacer bien para que no sea un
desastre silencioso: serializar todo lo que toca ORT, transmitir el cuerpo antes de escribir la
caché, comprobar el contrato por los dos caminos y decir siempre por qué se usa el respaldo. Lo que
te llevas a cualquier servidor de modelos: un fichero que carga no es un fichero que encaja.
La demo juega en WebGPU o en WASM con el motivo a la vista, la barra se mueve desde el primer trozo,
un fichero con otra anchura se rechaza con un mensaje en español y ?stage=test ejercita el camino
entero con 200 KB.
Lo siguiente es publicar: pesos en safetensors, los tres ONNX, el vocabulario y una model card
generada de las métricas medidas.