diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index e3f6d86..d960778 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -21,11 +21,11 @@ jobs: matrix: browser: [chromium, firefox, webkit] steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: run_install: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: pnpm @@ -33,7 +33,7 @@ jobs: - run: pnpm exec playwright install --with-deps ${{ matrix.browser }} - run: pnpm exec playwright test --project=${{ matrix.browser }} - if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: browser-evidence-${{ matrix.browser }} path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd83cda..9acfc77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,11 +29,11 @@ jobs: - os: ubuntu-latest node: 26 steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: run_install: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} cache: pnpm @@ -45,11 +45,11 @@ jobs: name: Production dependency audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: run_install: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 76edec5..ab17c3d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,8 +16,8 @@ jobs: name: Analyze JavaScript and TypeScript runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: javascript-typescript - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a06ebad..67fa2e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,13 +13,14 @@ jobs: name: Verify and publish prerelease assets runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - uses: pnpm/action-setup@v6 + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: run_install: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: pnpm diff --git a/README.md b/README.md index db86add..ff495b2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ pnpm add ./tabloom-0.3.0-alpha.2.tgz Verify the adjacent `.sha256` asset before installing in a controlled delivery pipeline. +For a complete Vite consumer using only public package exports, start with the +[pinned WebLLM starter](examples/vite-webllm/README.md). Its page and +SharedWorker share one configuration manifest and the repository package smoke +builds it against a freshly packed TabLoom archive. + ## Quick start ```ts diff --git a/eslint.config.js b/eslint.config.js index ec03a6b..1e4e057 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,16 @@ export default tseslint.config( ], }, }, + { + files: ['examples/vite-webllm/**/*.ts'], + languageOptions: { + parserOptions: { + project: ['./tsconfig.starter-lint.json'], + projectService: false, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, { files: ['scripts/**/*.mjs'], languageOptions: { diff --git a/examples/vite-webllm/README.md b/examples/vite-webllm/README.md new file mode 100644 index 0000000..0407f52 --- /dev/null +++ b/examples/vite-webllm/README.md @@ -0,0 +1,61 @@ +# TabLoom Vite + WebLLM starter + +This is a clean consumer of the released TabLoom package. The application and +SharedWorker import only documented package exports; neither entry point reaches +into the TabLoom source tree. + +## Pinned boundaries + +| Boundary | Pinned value | Change rule | +| ---------------- | ---------------------------------------------------- | -------------------------------------------------------- | +| TabLoom | Verified `0.3.0-alpha.2` GitHub release + lock SRI | Update `tabloomRelease`, lock, and all consumer gates | +| WebLLM | `0.2.84` | Update the adapter identity and re-run a real-model gate | +| Model | `SmolLM2-360M-Instruct-q4f16_1-MLC` at one HF commit | Update the commit and SRI values; expect a new download | +| Runtime identity | Runtime, immutable artifact URLs, SRI, and policies | Never reuse a fingerprint after any behavior change | +| Concurrency | One generation per owner | Keep at one for the current WebLLM adapter | + +`package.json` names the independently verified published TabLoom boundary; it +is intentionally separate from the repository's next unreleased package +version. The exact application policy lives in `src/runtime-config.ts`. Model +weights are resolved at one immutable Hugging Face revision; the config, +tokenizer, and WASM library additionally fail closed on SRI mismatch. Both the +page and SharedWorker derive the same fingerprint from those artifact identities +and the runtime policy, so incompatible deployments fail negotiation instead of +silently sharing a runtime. + +## Run + +Requires Node.js 24+, pnpm 11.13.0, a WebGPU-capable browser, and HTTPS or a +loopback development origin. + +```bash +pnpm install --frozen-lockfile +pnpm dev +``` + +Open `http://127.0.0.1:4173`. The first model load downloads provider assets and +can take time. The adaptive policy prefers a SharedWorker where its lifecycle is +portable; otherwise it selects the fenced page-owner path. In page-owner mode, +open the sibling page before submitting a prompt. + +From the repository root, the opt-in live gate exercises the pinned URLs, SRI, +owner handshake, streaming path, and one real generation in Chrome/WebGPU: + +```bash +TABLOOM_WEBLLM_LIVE=1 pnpm test:live:starter +``` + +## Alpha contract + +- There is no automatic cloud fallback or provider routing. +- Prompts stay in the selected local browser owner unless you add an external + adapter yourself. +- A request may execute again after owner takeover; external side effects need + an application idempotency key. +- Built-in telemetry excludes request and generated content, but application + logging remains your responsibility. +- The checked-in model and provider pair is the verified boundary, not a claim + that every browser, GPU, model, or later provider version works. + +From the repository root, `pnpm package:smoke` packs TabLoom, installs that fresh +archive into a temporary copy of this starter, and runs its production build. diff --git a/examples/vite-webllm/index.html b/examples/vite-webllm/index.html new file mode 100644 index 0000000..e45044f --- /dev/null +++ b/examples/vite-webllm/index.html @@ -0,0 +1,77 @@ + + + + + + + + TabLoom WebLLM starter + + +
+
+

Verified consumer starter

+

One local model runtime, shared across sibling pages.

+

+ This Vite app consumes only TabLoom's public package exports and keeps + the model, runtime identity, broker policy, and generation defaults in + one replaceable configuration module. +

+
+ +
+
+
+
Model
+
not loaded
+
+
+
Topology
+
starting
+
+
+
Role
+
starting
+
+
+
Readiness
+
starting
+
+
+
Epoch
+
0
+
+
+

+ Checking browser capabilities. +

+
+ +
+
+ + +
+ + +
+
+
Waiting for a request.
+
+ + +
+ + + diff --git a/examples/vite-webllm/package.json b/examples/vite-webllm/package.json new file mode 100644 index 0000000..eb739c7 --- /dev/null +++ b/examples/vite-webllm/package.json @@ -0,0 +1,28 @@ +{ + "name": "tabloom-vite-webllm-starter", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "packageManager": "pnpm@11.13.0", + "tabloomRelease": { + "version": "0.3.0-alpha.2", + "integrity": "sha512-E9LQMl+dovCjAksjA6jrmfFemviu6uT45IwjUK2dcsvnINiUPPiZUzXHd8pwbTHQhPeIft0SrnFcdTFNdxL3hg==", + "webLlmVersion": "0.2.84" + }, + "scripts": { + "build": "tsc --noEmit && vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "@aantenore/tabloom": "https://github.com/aantenore/tabloom/releases/download/v0.3.0-alpha.2/tabloom-0.3.0-alpha.2.tgz", + "@mlc-ai/web-llm": "0.2.84" + }, + "devDependencies": { + "typescript": "6.0.3", + "vite": "8.1.5" + } +} diff --git a/examples/vite-webllm/pnpm-lock.yaml b/examples/vite-webllm/pnpm-lock.yaml new file mode 100644 index 0000000..f2298b5 --- /dev/null +++ b/examples/vite-webllm/pnpm-lock.yaml @@ -0,0 +1,552 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aantenore/tabloom': + specifier: https://github.com/aantenore/tabloom/releases/download/v0.3.0-alpha.2/tabloom-0.3.0-alpha.2.tgz + version: https://github.com/aantenore/tabloom/releases/download/v0.3.0-alpha.2/tabloom-0.3.0-alpha.2.tgz(@mlc-ai/web-llm@0.2.84) + '@mlc-ai/web-llm': + specifier: 0.2.84 + version: 0.2.84 + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5 + +packages: + + '@aantenore/tabloom@https://github.com/aantenore/tabloom/releases/download/v0.3.0-alpha.2/tabloom-0.3.0-alpha.2.tgz': + resolution: {integrity: sha512-E9LQMl+dovCjAksjA6jrmfFemviu6uT45IwjUK2dcsvnINiUPPiZUzXHd8pwbTHQhPeIft0SrnFcdTFNdxL3hg==, tarball: https://github.com/aantenore/tabloom/releases/download/v0.3.0-alpha.2/tabloom-0.3.0-alpha.2.tgz} + version: 0.3.0-alpha.2 + engines: {node: '>=24'} + peerDependencies: + '@mlc-ai/web-llm': 0.2.84 + peerDependenciesMeta: + '@mlc-ai/web-llm': + optional: true + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@mlc-ai/web-llm@0.2.84': + resolution: {integrity: sha512-hrOWzK4/nGNmgoRKT8pgVmZZ2oEPpbblIWQOwpqNyvK2dysHw3KVB1gNJOuRcQfKOPhucEhX1NJzXzgMDnwSCQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@aantenore/tabloom@https://github.com/aantenore/tabloom/releases/download/v0.3.0-alpha.2/tabloom-0.3.0-alpha.2.tgz(@mlc-ai/web-llm@0.2.84)': + dependencies: + idb: 8.0.3 + zod: 4.4.3 + optionalDependencies: + '@mlc-ai/web-llm': 0.2.84 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@mlc-ai/web-llm@0.2.84': + dependencies: + loglevel: 1.9.2 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + idb@8.0.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + loglevel@1.9.2: {} + + nanoid@3.3.16: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + vite@8.1.5: + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + zod@4.4.3: {} diff --git a/examples/vite-webllm/src/main.ts b/examples/vite-webllm/src/main.ts new file mode 100644 index 0000000..0f3a2d3 --- /dev/null +++ b/examples/vite-webllm/src/main.ts @@ -0,0 +1,170 @@ +import TabLoomWorker from './tabloom.worker?sharedworker'; +import { WebLlmInferenceAdapter } from '@aantenore/tabloom/adapters/webllm'; +import { createAdaptiveBrowserBroker } from '@aantenore/tabloom/shared-worker'; +import { resolveRuntimeConfig } from './runtime-config'; +import './styles.css'; + +const elements = { + epoch: requiredElement('epoch'), + form: requiredElement('form'), + model: requiredElement('model'), + output: requiredElement('output'), + peer: requiredElement('peer'), + prompt: requiredElement('prompt'), + readiness: requiredElement('readiness'), + role: requiredElement('role'), + status: requiredElement('status'), + submit: requiredElement('submit'), + topology: requiredElement('topology'), +}; + +elements.peer.addEventListener('click', () => { + window.open(window.location.href, '_blank', 'noopener'); +}); + +void start(); + +async function start(): Promise { + if (!window.isSecureContext || !Reflect.has(navigator, 'gpu')) { + setStatus( + 'WebGPU requires a compatible browser on HTTPS or loopback.', + true, + ); + return; + } + + const runtime = await resolveRuntimeConfig(); + elements.model.textContent = runtime.modelId; + let active = false; + let canSubmit = false; + let terminalStatus = false; + + const adapter = new WebLlmInferenceAdapter({ + engineConfig: runtime.engineConfig, + modelId: runtime.modelId, + onProgress: ({ progress, text }) => { + setStatus( + `${text || 'Loading the local runtime'} (${Math.round(progress * 100)}%)`, + ); + }, + }); + + try { + const selection = await createAdaptiveBrowserBroker({ + adapter, + config: runtime.broker, + topology: { + lifecyclePolicy: runtime.topology.lifecyclePolicy, + mode: runtime.topology.mode, + name: runtime.workerName, + requiredCapabilities: runtime.topology.requiredCapabilities, + workerFactory: ({ name }) => new TabLoomWorker({ name }), + }, + }); + const broker = selection.broker; + elements.topology.textContent = selection.topology; + + const refreshSubmit = () => { + elements.submit.disabled = active || !canSubmit; + }; + const unsubscribe = broker.subscribe((snapshot) => { + elements.epoch.textContent = String(snapshot.epoch); + elements.readiness.textContent = snapshot.readiness; + elements.role.textContent = snapshot.role; + canSubmit = + snapshot.readiness === 'ready' && + (selection.topology === 'shared-worker' || snapshot.role === 'peer'); + if (!active && !terminalStatus) { + if ( + snapshot.readiness === 'ready' && + selection.topology === 'page-owner' && + snapshot.role === 'leader' + ) { + setStatus('Runtime ready. Open a sibling page to submit work.'); + } else if (snapshot.readiness === 'ready') { + setStatus('Runtime ready for local inference.'); + } + } + refreshSubmit(); + }); + + elements.form.addEventListener('submit', (event) => { + event.preventDefault(); + if (!active && canSubmit) { + void runPrompt(); + } + }); + + async function runPrompt(): Promise { + const prompt = elements.prompt.value.trim(); + if (prompt.length === 0) { + terminalStatus = true; + setStatus('Enter a prompt before running inference.', true); + return; + } + + active = true; + terminalStatus = false; + refreshSubmit(); + elements.output.textContent = ''; + setStatus('Running on the selected local owner.'); + + try { + const session = broker.request({ + max_tokens: runtime.generation.maxTokens, + messages: [{ content: prompt, role: 'user' }], + stream_options: { include_usage: true }, + temperature: runtime.generation.temperature, + }); + const resultPromise = session.result; + void resultPromise.catch(() => undefined); + let streamedText = ''; + for await (const chunk of session) { + streamedText += chunk.choices[0]?.delta.content ?? ''; + elements.output.textContent = streamedText; + } + const result = await resultPromise; + elements.output.textContent = result.text; + terminalStatus = true; + setStatus('Completed locally.'); + } catch (cause) { + terminalStatus = true; + setStatus(messageFrom(cause), true); + } finally { + active = false; + refreshSubmit(); + } + } + + window.addEventListener( + 'pagehide', + () => { + unsubscribe(); + void broker.stop(); + }, + { once: true }, + ); + + setStatus('Starting the broker and local runtime.'); + await broker.start(); + } catch (cause) { + setStatus(messageFrom(cause), true); + } +} + +function requiredElement(field: string): T { + const element = document.querySelector(`[data-field="${field}"]`); + if (element === null) { + throw new Error(`Missing required element: ${field}`); + } + return element as T; +} + +function setStatus(message: string, error = false): void { + elements.status.textContent = message; + elements.status.classList.toggle('error', error); +} + +function messageFrom(cause: unknown): string { + return cause instanceof Error ? cause.message : 'Local inference failed.'; +} diff --git a/examples/vite-webllm/src/runtime-config.ts b/examples/vite-webllm/src/runtime-config.ts new file mode 100644 index 0000000..bc2f0d9 --- /dev/null +++ b/examples/vite-webllm/src/runtime-config.ts @@ -0,0 +1,107 @@ +import { createRuntimeFingerprint } from '@aantenore/tabloom/core'; +import type { AppConfig, MLCEngineConfig, ModelRecord } from '@mlc-ai/web-llm'; + +export const runtimeManifest = { + adapter: 'webllm@0.2.84', + build: 'tabloom-vite-webllm-starter@1', + configuration: 'single-generation-defaults@1', + controlPlane: 'tabloom@0.3.0-alpha.2', + controlPlaneIntegrity: + 'sha512-E9LQMl+dovCjAksjA6jrmfFemviu6uT45IwjUK2dcsvnINiUPPiZUzXHd8pwbTHQhPeIft0SrnFcdTFNdxL3hg==', + modelConfigIntegrity: + 'sha384-q5g7Vr8NPimskWQfkKEHbD/LHSezXA8Jd/6026NLSMR2rWz/4RoTUK9HsuC3seC4', + modelId: 'SmolLM2-360M-Instruct-q4f16_1-MLC', + modelLibraryIntegrity: + 'sha384-orS1wyCqzySQio0yY+PZDZZAZclbgZNdjmO4FRo/7SF1jyUPqMHVwkvUo28pPsCB', + modelLibraryUrl: + 'https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/025bcaf3780fa8254f5e5efd3bfea0a5397248f4/web-llm-models/v0_2_84/base/SmolLM2-360M-Instruct-q4f16_1_cs1k-webgpu.wasm', + modelUrl: + 'https://huggingface.co/mlc-ai/SmolLM2-360M-Instruct-q4f16_1-MLC/resolve/3a622fd89e0216e8bb10c410c007c786baa8a033/', + tokenizerIntegrity: + 'sha384-TYQ9bvLe/Vytaer+YyXqKp0FA6bauwU+PxOlTcHSoLqTCPKrQwSHApOPo4MCLoB0', +} as const; + +const modelOptions = { + contextWindowSize: 4_096, + lowResourceRequired: true, + requiredFeatures: ['shader-f16'], + vramRequiredMb: 376.06, +} as const; + +const artifactPolicy = { + cacheBackend: 'cache', + integrityMode: 'error', + tokenizerFiles: ['tokenizer.json'], +} as const; + +const modelRecord = { + integrity: { + config: runtimeManifest.modelConfigIntegrity, + model_lib: runtimeManifest.modelLibraryIntegrity, + onFailure: artifactPolicy.integrityMode, + tokenizer: { + [artifactPolicy.tokenizerFiles[0]]: runtimeManifest.tokenizerIntegrity, + }, + }, + low_resource_required: modelOptions.lowResourceRequired, + model: runtimeManifest.modelUrl, + model_id: runtimeManifest.modelId, + model_lib: runtimeManifest.modelLibraryUrl, + overrides: { context_window_size: modelOptions.contextWindowSize }, + required_features: [...modelOptions.requiredFeatures], + vram_required_MB: modelOptions.vramRequiredMb, +} satisfies ModelRecord; + +const appConfig = { + cacheBackend: artifactPolicy.cacheBackend, + model_list: [modelRecord], +} satisfies AppConfig; + +const engineConfig = { appConfig } satisfies MLCEngineConfig; + +export const starterPolicy = { + broker: { + heartbeatIntervalMs: 500, + leaderTimeoutMs: 3_000, + maxConcurrent: 1, + namespace: 'tabloom-vite-webllm-starter', + queueCapacity: 4, + requestTimeoutMs: 180_000, + }, + generation: { + maxTokens: 96, + temperature: 0, + }, + host: { + clientLivenessIntervalMs: 1_000, + clientLivenessTimeoutMs: 5_000, + idleTimeoutMs: 30_000, + }, + topology: { + lifecyclePolicy: 'portable', + mode: 'auto', + requiredCapabilities: ['webgpu'], + }, +} as const; + +export async function resolveRuntimeConfig() { + const runtimeFingerprint = await createRuntimeFingerprint({ + ...runtimeManifest, + artifactPolicy: JSON.stringify(artifactPolicy), + brokerPolicy: JSON.stringify(starterPolicy.broker), + generationPolicy: JSON.stringify(starterPolicy.generation), + hostPolicy: JSON.stringify(starterPolicy.host), + modelOptions: JSON.stringify(modelOptions), + topologyPolicy: JSON.stringify(starterPolicy.topology), + }); + + return { + broker: { ...starterPolicy.broker, runtimeFingerprint }, + engineConfig, + generation: starterPolicy.generation, + host: starterPolicy.host, + modelId: runtimeManifest.modelId, + topology: starterPolicy.topology, + workerName: `${starterPolicy.broker.namespace}:${runtimeFingerprint}`, + } as const; +} diff --git a/examples/vite-webllm/src/styles.css b/examples/vite-webllm/src/styles.css new file mode 100644 index 0000000..0fc50db --- /dev/null +++ b/examples/vite-webllm/src/styles.css @@ -0,0 +1,156 @@ +:root { + color: #e8eef4; + background: #071014; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + radial-gradient( + circle at 80% 10%, + rgb(27 121 111 / 28%), + transparent 28rem + ), + #071014; +} + +main { + width: min(960px, calc(100% - 2rem)); + margin: 0 auto; + padding: 5rem 0 3rem; +} + +header { + max-width: 760px; +} + +.eyebrow, +dt, +label { + color: #63dec9; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1 { + margin: 0.5rem 0 1rem; + font-size: clamp(2.25rem, 6vw, 4.75rem); + line-height: 0.98; + letter-spacing: -0.05em; +} + +.lede, +footer { + color: #9fb0bb; + line-height: 1.65; +} + +.runtime, +.prompt-card { + margin-top: 2rem; + padding: 1.25rem; + border: 1px solid #263940; + border-radius: 1rem; + background: rgb(12 27 33 / 84%); + box-shadow: 0 1.5rem 5rem rgb(0 0 0 / 20%); +} + +dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 1rem; + margin: 0; +} + +dd { + overflow-wrap: anywhere; + margin: 0.35rem 0 0; +} + +.status { + margin: 1.25rem 0 0; + color: #b9fff2; +} + +.status.error { + color: #ff9f92; +} + +form { + display: grid; + gap: 0.75rem; +} + +textarea, +pre { + width: 100%; + border: 1px solid #314b54; + border-radius: 0.75rem; + color: inherit; + background: #08161b; + font: inherit; +} + +textarea { + resize: vertical; + padding: 1rem; + line-height: 1.5; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +button { + padding: 0.75rem 1rem; + border: 1px solid #63dec9; + border-radius: 999px; + color: #06110f; + background: #63dec9; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +button.secondary { + color: #b9fff2; + background: transparent; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +pre { + min-height: 7rem; + margin: 1rem 0 0; + padding: 1rem; + overflow: auto; + white-space: pre-wrap; +} + +footer { + margin-top: 1.5rem; + font-size: 0.875rem; +} diff --git a/examples/vite-webllm/src/tabloom.worker.ts b/examples/vite-webllm/src/tabloom.worker.ts new file mode 100644 index 0000000..35a1d41 --- /dev/null +++ b/examples/vite-webllm/src/tabloom.worker.ts @@ -0,0 +1,15 @@ +import { WebLlmInferenceAdapter } from '@aantenore/tabloom/adapters/webllm'; +import { createSharedWorkerBrokerHost } from '@aantenore/tabloom/shared-worker'; +import { resolveRuntimeConfig } from './runtime-config'; + +const runtime = await resolveRuntimeConfig(); + +createSharedWorkerBrokerHost({ + adapter: new WebLlmInferenceAdapter({ + engineConfig: runtime.engineConfig, + modelId: runtime.modelId, + }), + config: runtime.broker, + ...runtime.host, + scope: globalThis, +}); diff --git a/examples/vite-webllm/tsconfig.json b/examples/vite-webllm/tsconfig.json new file mode 100644 index 0000000..6d818ac --- /dev/null +++ b/examples/vite-webllm/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "lib": ["ES2024", "DOM", "DOM.Iterable", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["vite/client"], + "useDefineForClassFields": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/vite-webllm/vite.config.ts b/examples/vite-webllm/vite.config.ts new file mode 100644 index 0000000..9b4d3f3 --- /dev/null +++ b/examples/vite-webllm/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + emptyOutDir: true, + outDir: 'dist', + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + server: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + worker: { format: 'es' }, +}); diff --git a/package.json b/package.json index b5f29bc..e79eeb7 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "test:browser:chromium": "playwright test --project=chromium", "test:browser:preview": "playwright test --config=playwright.preview.config.ts", "test:live:webllm": "playwright test --config=playwright.webllm.config.ts", + "test:live:starter": "playwright test --config=playwright.starter-webllm.config.ts", "test:unit": "vitest run --coverage", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.demo.json --noEmit" }, diff --git a/playwright.starter-webllm.config.ts b/playwright.starter-webllm.config.ts new file mode 100644 index 0000000..018ef16 --- /dev/null +++ b/playwright.starter-webllm.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + expect: { timeout: 20_000 }, + fullyParallel: false, + outputDir: 'test-results/starter-webllm', + reporter: process.env['CI'] ? [['github'], ['line']] : 'line', + retries: 0, + testDir: './tests/live-starter', + timeout: 900_000, + use: { + baseURL: 'http://127.0.0.1:4173', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: 'pnpm --dir examples/vite-webllm dev', + reuseExistingServer: false, + timeout: 30_000, + url: 'http://127.0.0.1:4173', + }, + workers: 1, + projects: [ + { + name: 'chrome-webgpu', + use: { + ...devices['Desktop Chrome'], + channel: 'chrome', + launchOptions: { args: ['--enable-unsafe-webgpu'] }, + }, + }, + ], +}); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 2ea0587..9e7fda6 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { + cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -10,12 +12,13 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); const temporaryRoot = mkdtempSync(join(tmpdir(), 'tabloom-package-')); const consumerRoot = join(temporaryRoot, 'consumer'); +const starterRoot = join(temporaryRoot, 'vite-webllm-starter'); const pnpmCli = process.env.npm_execpath; assert.ok(pnpmCli, 'Run this smoke through the package manager script.'); @@ -266,7 +269,153 @@ try { ), 'The optional provider import must remain lazy.', ); - console.log(`Package smoke passed: ${archive}`); + + const starterSource = join(repositoryRoot, 'examples', 'vite-webllm'); + cpSync(starterSource, starterRoot, { + filter: (source) => { + const segments = relative(starterSource, source).split(sep); + return !segments.some((segment) => + ['dist', 'node_modules'].includes(segment), + ); + }, + recursive: true, + }); + assert.equal( + existsSync(join(starterRoot, 'node_modules')), + false, + 'The starter smoke must begin without copied dependencies.', + ); + assertPublicPackageImports(starterRoot); + const starterPackagePath = join(starterRoot, 'package.json'); + const starterPackage = parsePackageManifest( + readFileSync(starterPackagePath, 'utf8'), + ); + const repositoryPackage = parsePackageManifest( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), + ); + const releaseBoundary = starterPackage['tabloomRelease']; + assert.ok( + isRecord(releaseBoundary) && + typeof releaseBoundary['version'] === 'string' && + typeof releaseBoundary['integrity'] === 'string' && + typeof releaseBoundary['webLlmVersion'] === 'string', + 'The starter must declare its verified published TabLoom boundary.', + ); + assert.match( + releaseBoundary['version'], + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u, + ); + assert.match(releaseBoundary['integrity'], /^sha512-[A-Za-z0-9+/]+={0,2}$/u); + assertPinnedRuntimeArtifacts( + starterRoot, + releaseBoundary['webLlmVersion'], + releaseBoundary['version'], + releaseBoundary['integrity'], + ); + const expectedArchive = + `https://github.com/aantenore/tabloom/releases/download/` + + `v${releaseBoundary['version']}/tabloom-${releaseBoundary['version']}.tgz`; + assert.equal( + starterPackage.dependencies['@aantenore/tabloom'], + expectedArchive, + 'The starter archive URL must match its verified published boundary.', + ); + const starterLock = readFileSync(join(starterRoot, 'pnpm-lock.yaml'), 'utf8'); + assert.ok( + starterLock.includes(expectedArchive) && + starterLock.includes(`integrity: ${releaseBoundary['integrity']}`), + 'The starter lock must bind the declared release URL and archive integrity.', + ); + assert.equal( + starterPackage.dependencies['@mlc-ai/web-llm'], + releaseBoundary['webLlmVersion'], + 'The starter WebLLM version must match its published TabLoom boundary.', + ); + assert.ok( + starterLock.includes( + `'@mlc-ai/web-llm': ${releaseBoundary['webLlmVersion']}`, + ), + 'The starter lock must retain the published TabLoom WebLLM peer boundary.', + ); + execFileSync( + process.execPath, + [pnpmCli, 'install', '--frozen-lockfile', '--ignore-scripts', '--silent'], + { cwd: starterRoot, stdio: 'pipe' }, + ); + const remoteInstalledPackage = parsePackageManifest( + readFileSync( + join( + starterRoot, + 'node_modules', + '@aantenore', + 'tabloom', + 'package.json', + ), + 'utf8', + ), + ); + assert.equal( + remoteInstalledPackage.version, + releaseBoundary['version'], + 'The frozen starter install must resolve the declared published boundary.', + ); + execFileSync( + process.execPath, + [pnpmCli, 'audit', '--prod', '--audit-level=high'], + { cwd: starterRoot, stdio: 'pipe' }, + ); + const localArchiveSpecifier = `file:${relative( + starterRoot, + join(temporaryRoot, archive), + ) + .split(sep) + .join('/')}`; + starterPackage.dependencies['@aantenore/tabloom'] = localArchiveSpecifier; + writeFileSync( + starterPackagePath, + `${JSON.stringify(starterPackage, null, 2)}\n`, + ); + execFileSync( + process.execPath, + [ + pnpmCli, + 'install', + '--no-frozen-lockfile', + '--ignore-scripts', + '--silent', + ], + { cwd: starterRoot, stdio: 'pipe' }, + ); + assert.ok( + readFileSync(join(starterRoot, 'pnpm-lock.yaml'), 'utf8').includes( + localArchiveSpecifier, + ), + 'The current-source build must resolve TabLoom from the freshly packed local archive.', + ); + execFileSync(process.execPath, [pnpmCli, 'build'], { + cwd: starterRoot, + stdio: 'pipe', + }); + const starterAssets = listFiles(join(starterRoot, 'dist')); + assert.ok( + starterAssets.some((entry) => /tabloom\.worker-[^/]+\.js$/u.test(entry)), + 'The packed-artifact starter build did not emit its SharedWorker chunk.', + ); + const installedPackage = parsePackageManifest( + readFileSync( + join( + starterRoot, + 'node_modules', + '@aantenore', + 'tabloom', + 'package.json', + ), + 'utf8', + ), + ); + assert.equal(installedPackage.version, repositoryPackage.version); + + console.log(`Package and verified starter smoke passed: ${archive}`); } finally { rmSync(temporaryRoot, { force: true, recursive: true }); } @@ -283,3 +432,131 @@ function listFiles(root) { : [entry]; }); } + +/** + * @param {string} starter + * @returns {void} + */ +function assertPublicPackageImports(starter) { + for (const entry of listFiles(join(starter, 'src'))) { + if (!entry.endsWith('.ts')) { + continue; + } + const source = readFileSync(join(starter, 'src', entry), 'utf8'); + assert.doesNotMatch( + source, + /@aantenore\/tabloom\/(?:dist|src)|(?:\.\.\/)+src\//u, + `${entry} imports an internal TabLoom module.`, + ); + } +} + +/** + * @param {string} starter + * @param {string} webLlmVersion + * @param {string} tabloomVersion + * @param {string} tabloomIntegrity + * @returns {void} + */ +function assertPinnedRuntimeArtifacts( + starter, + webLlmVersion, + tabloomVersion, + tabloomIntegrity, +) { + const source = readFileSync( + join(starter, 'src', 'runtime-config.ts'), + 'utf8', + ); + assert.doesNotMatch( + source, + /(?:raw\.githubusercontent\.com|huggingface\.co)[^'\n]*\/main(?:\/|')/u, + 'The verified starter must not resolve model artifacts from a mutable main branch.', + ); + assert.match( + source, + /huggingface\.co\/[^'\n]+\/resolve\/[0-9a-f]{40}\//u, + 'The model weights must resolve from an immutable Hugging Face revision.', + ); + assert.match( + source, + /raw\.githubusercontent\.com\/[^'\n]+\/[0-9a-f]{40}\//u, + 'The model library must resolve from an immutable Git commit.', + ); + assert.equal( + (source.match(/'sha384-[A-Za-z0-9+/=]+'/gu) ?? []).length, + 3, + 'The model config, tokenizer, and model library require explicit SRI.', + ); + assert.match( + source, + /integrityMode: 'error'/u, + 'Runtime artifact integrity must fail closed.', + ); + assert.match(source, /onFailure: artifactPolicy\.integrityMode/u); + assert.match(source, /cacheBackend: artifactPolicy\.cacheBackend/u); + assert.match( + source, + /createRuntimeFingerprint\(\{\s*\.\.\.runtimeManifest,/u, + 'The runtime fingerprint must bind the immutable artifact manifest.', + ); + assert.ok( + source.includes(`adapter: 'webllm@${webLlmVersion}'`), + 'The fingerprinted adapter identity must match the published WebLLM boundary.', + ); + assert.ok( + source.includes(`controlPlane: 'tabloom@${tabloomVersion}'`) && + source.includes(`controlPlaneIntegrity:\n '${tabloomIntegrity}'`), + 'The runtime fingerprint must bind the verified TabLoom release boundary.', + ); + + for (const entry of ['main.ts', 'tabloom.worker.ts']) { + assert.match( + readFileSync(join(starter, 'src', entry), 'utf8'), + /engineConfig: runtime\.engineConfig/u, + `${entry} must use the integrity-pinned WebLLM AppConfig.`, + ); + } +} + +/** + * @typedef {{ dependencies: Record, version: string, [key: string]: unknown }} PackageManifest + */ + +/** + * @param {string} source + * @returns {PackageManifest} + */ +function parsePackageManifest(source) { + const parsed = /** @type {unknown} */ (JSON.parse(source)); + assert.ok(isRecord(parsed), 'The package manifest must be an object.'); + assert.ok( + isStringRecord(parsed['dependencies']), + 'The package manifest must have string dependencies.', + ); + assert.equal( + typeof parsed['version'], + 'string', + 'The package manifest must have a version.', + ); + return /** @type {PackageManifest} */ (parsed); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isStringRecord(value) { + return ( + isRecord(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ); +} diff --git a/tests/live-starter/vite-webllm-starter.spec.ts b/tests/live-starter/vite-webllm-starter.spec.ts new file mode 100644 index 0000000..20887d6 --- /dev/null +++ b/tests/live-starter/vite-webllm-starter.spec.ts @@ -0,0 +1,83 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +const LIVE_ENABLED = process.env['TABLOOM_WEBLLM_LIVE'] === '1'; + +test.describe('verified Vite WebLLM starter', () => { + test.skip( + !LIVE_ENABLED, + 'Set TABLOOM_WEBLLM_LIVE=1 to download and run the pinned live model.', + ); + + test('loads pinned artifacts and completes through the selected owner', async ({ + context, + }, testInfo) => { + const errors: string[] = []; + const first = await openStarter(context, errors); + await expect(first).toHaveTitle('TabLoom WebLLM starter'); + await expect(first.locator('[data-field="topology"]')).not.toHaveText( + 'starting', + { timeout: 30_000 }, + ); + + const topology = await first + .locator('[data-field="topology"]') + .textContent(); + const peer = + topology === 'page-owner' ? await openStarter(context, errors) : first; + await expect(peer.locator('[data-field="readiness"]')).toHaveText('ready', { + timeout: 600_000, + }); + await expect(peer.locator('[data-field="submit"]')).toBeEnabled(); + + await peer.locator('[data-field="prompt"]').fill('Return only: ready'); + await peer.locator('[data-field="submit"]').click(); + const status = peer.locator('[data-field="status"]'); + await expect(status).toHaveText('Completed locally.', { + timeout: 180_000, + }); + await peer.waitForTimeout(1_500); + await expect(status).toHaveText('Completed locally.'); + await expect(status).not.toHaveClass(/error/u); + await expect + .poll( + async () => + (await peer.locator('[data-field="output"]').textContent())?.trim() + .length ?? 0, + ) + .toBeGreaterThan(0); + expect(errors).toEqual([]); + + await testInfo.attach('starter-runtime-evidence.json', { + body: Buffer.from( + JSON.stringify( + { + browserVersion: context.browser()?.version() ?? 'unknown', + modelId: await peer.locator('[data-field="model"]').textContent(), + outputLength: + ( + await peer.locator('[data-field="output"]').textContent() + )?.trim().length ?? 0, + role: await peer.locator('[data-field="role"]').textContent(), + topology, + }, + null, + 2, + ), + ), + contentType: 'application/json', + }); + }); +}); + +async function openStarter( + context: BrowserContext, + errors: string[], +): Promise { + const page = await context.newPage(); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto('/'); + return page; +} diff --git a/tsconfig.json b/tsconfig.json index c146274..b19c3be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,9 +31,11 @@ "scripts", "tests/e2e", "tests/live", + "tests/live-starter", "tests/unit", "playwright.config.ts", "playwright.preview.config.ts", + "playwright.starter-webllm.config.ts", "playwright.webllm.config.ts", "vite.config.ts", "vitest.config.ts", diff --git a/tsconfig.starter-lint.json b/tsconfig.starter-lint.json new file mode 100644 index 0000000..8bea3c5 --- /dev/null +++ b/tsconfig.starter-lint.json @@ -0,0 +1,11 @@ +{ + "extends": "./examples/vite-webllm/tsconfig.json", + "compilerOptions": { + "paths": { + "@aantenore/tabloom/adapters/webllm": ["./src/adapters/webllm.ts"], + "@aantenore/tabloom/core": ["./src/core.ts"], + "@aantenore/tabloom/shared-worker": ["./src/shared-worker.ts"] + } + }, + "include": ["examples/vite-webllm/src", "examples/vite-webllm/vite.config.ts"] +}