diff --git a/.github/actions/typst-cache/action.yml b/.github/actions/typst-cache/action.yml index d313be41cb..abc53ec304 100644 --- a/.github/actions/typst-cache/action.yml +++ b/.github/actions/typst-cache/action.yml @@ -1,12 +1,22 @@ name: Restore Typst WASM cache description: Reuse compiled Typst WASM after dependency installation +outputs: + hash: + description: Content hash of the Typst WASM build inputs + value: ${{ steps.fingerprint.outputs.hash }} runs: using: composite steps: + - name: Fingerprint Typst WASM inputs + id: fingerprint + shell: bash + run: | + hash=$(node packages/xl-typst-compiler/scripts/wasm-cache.mjs) + echo "hash=$hash" >> "$GITHUB_OUTPUT" # Cache just the distributable (~25 MB), not the multi-GB Rust target tree. # ensure-wasm.mjs still verifies its content hash before using this output. - name: Cache Typst WASM uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: packages/xl-typst-compiler/pkg - key: typst-wasm-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/xl-typst-compiler/rust/Cargo.toml', 'packages/xl-typst-compiler/rust/Cargo.lock', 'packages/xl-typst-compiler/rust/rust-toolchain.toml', 'packages/xl-typst-compiler/rust/src/**', 'packages/xl-typst-compiler/scripts/ensure-wasm.mjs', 'packages/xl-typst-compiler/package.json', 'packages/xl-typst-compiler/node_modules/wasm-pack/package.json') }} + key: typst-wasm-v3-${{ steps.fingerprint.outputs.hash }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5c82861bb..aa03399041 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,12 +104,30 @@ jobs: cache: true - uses: $/.github/actions/typst-cache + id: typst-cache # Compiles the Rust wasm when missing/stale (rustup is bootstrapped by # the build task itself) plus the TS wrapper's dist/types. - name: Build Typst compiler package run: vp run --filter @blocknote/xl-typst-compiler build + # Developer downloads accept only successful main push runs. Keep this + # separate from the full package artifact consumed by the PDF jobs. + - name: Upload content-addressed WASM + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: typst-wasm-v3-${{ steps.typst-cache.outputs.hash }} + path: | + packages/xl-typst-compiler/pkg/blocknote_typst_wasm.js + packages/xl-typst-compiler/pkg/blocknote_typst_wasm.d.ts + packages/xl-typst-compiler/pkg/blocknote_typst_wasm_bg.wasm + packages/xl-typst-compiler/pkg/blocknote_typst_wasm_bg.wasm.d.ts + packages/xl-typst-compiler/pkg/.build-manifest.json + include-hidden-files: true + if-no-files-found: error + retention-days: 90 + - name: Upload compiler build outputs uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 8e78d700f9..2191e5b04d 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -47,7 +47,11 @@ jobs: # rustup auto-provisions the pinned toolchain from # rust/rust-toolchain.toml. - name: Build packages - run: vp run -r build + run: vp run --no-cache -r build + env: + # Release provenance must come from this source checkout, not reused + # developer/CI WASM outputs or Vite task-cache replay. + BLOCKNOTE_TYPST_SOURCE_ONLY: "1" - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/packages/xl-typst-compiler/README.md b/packages/xl-typst-compiler/README.md index 9812099d8a..066264acbb 100644 --- a/packages/xl-typst-compiler/README.md +++ b/packages/xl-typst-compiler/README.md @@ -48,12 +48,63 @@ The wasm module (`pkg/`) is built from `rust/` (toolchain pinned in pnpm exec vp run --filter @blocknote/xl-typst-compiler build ``` -The build task self-provisions: `scripts/ensure-wasm.mjs` compiles `pkg/` -from `rust/` when it is missing or stale (content-hashed), before the -TypeScript build. It needs rustup locally (the pinned toolchain and wasm32 -target auto-install); on Vercel it bootstraps rustup itself and keeps the -cargo caches under `node_modules/.cache`, so only the first build on a -fresh cache pays the full compile. `npm run build:wasm` forces a rebuild. +The build task checks the Rust sources, pinned toolchain, resolved wasm-pack +version, and the output-producing `scripts/wasm-build.mjs` contract using one +content hash shared with CI. That contract owns the wasm-pack invocation and +output file list and is hashed automatically; cache/download orchestration and +logging changes do not invalidate otherwise identical WASM. Keep changes to +compilation settings in that contract. Before the +TypeScript build, it tries these paths in order: + +1. Reuse matching `pkg/` files after verifying their checksums. +2. Restore matching files from the per-user cache (shared across worktrees). +3. Download an exact-hash artifact from a successful `main` push of the + repository's build workflow. PR artifacts are never accepted. +4. Compile from source with rustup (the pinned toolchain and wasm32 target + auto-install). CI/Vercel bootstrap rustup if needed; local machines need + rustup installed. Vercel keeps Rust caches under `node_modules/.cache`. + +Developer downloads require `gh auth login`, `GH_TOKEN`, or `GITHUB_TOKEN` +with read access to Actions artifacts. No interactive authentication happens +during builds. Missing credentials, offline requests, expired artifacts, and +invalid downloads fall back to source compilation; downloads time out after +30 seconds. ZIP archives are read using `fflate`; no system `unzip` executable +or newer Node version is required. +Artifacts are retained for 90 days, and a new +fingerprint only becomes downloadable after a successful `main` build. + +The cache lives at `$XDG_CACHE_HOME/blocknote/typst-wasm-v3` (default +`~/.cache/blocknote/typst-wasm-v3`). Override it with +`BLOCKNOTE_TYPST_CACHE_DIR`. Entries are content-addressed and can be removed +when no builds are running to reclaim disk space; there is no automatic eviction. +Set `BLOCKNOTE_TYPST_DOWNLOAD=0` to disable remote downloads while keeping local +reuse. `vp run --filter @blocknote/xl-typst-compiler build:wasm` bypasses all +caches and forces source compilation. `BLOCKNOTE_TYPST_SOURCE_ONLY=1` also +bypasses all WASM reuse; release builds use this with task caching disabled. + +The v3 cache/artifact schema contains four generated files and one +`.build-manifest.json` recording the input hash and file checksums. It replaces +v2's redundant `.build-hash` metadata with a new fingerprint and namespace: +old local outputs rebuild or restore, v2 remote artifacts are not requested, +and old shared-cache entries may be removed manually. A source build removes +leftover `.build-hash` metadata from `pkg/`. + +For each matching artifact, the producer trust check and archive download run +in parallel. An untrusted producer aborts/discards the speculative download; +no archive is extracted or installed before trust is established. Only one +candidate is downloaded at a time. +Downloads are staged beside `pkg/` and checksum-verified before being renamed +into place without a second output copy. The checksums +detect corruption; trust comes from the restricted workflow producer, not the +source hash alone. Simultaneous builds in one checkout are serialized. If a +process is forcibly killed, remove `node_modules/.typst-wasm.lock` inside this +package after confirming no build is still running. + +On native Windows, source builds invoke wasm-pack through Node rather than a +shell shim. If Rust is missing, the build exits with rustup setup instructions +instead of attempting the Unix installer. Native Windows has not been tested +end to end. + `pkg/` is a build output (not checked in); the published npm package includes it. diff --git a/packages/xl-typst-compiler/package.json b/packages/xl-typst-compiler/package.json index b774f8af34..53198635ca 100644 --- a/packages/xl-typst-compiler/package.json +++ b/packages/xl-typst-compiler/package.json @@ -71,7 +71,6 @@ }, "scripts": { "dev": "vp dev", - "build:wasm": "node scripts/ensure-wasm.mjs --force", "lint": "vp lint src", "test": "vp test --run", "test-watch": "vp test watch", @@ -79,6 +78,7 @@ }, "devDependencies": { "@types/node": "22.13.13", + "fflate": "^0.8.3", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^7.0.2", diff --git a/packages/xl-typst-compiler/scripts/ensure-wasm.mjs b/packages/xl-typst-compiler/scripts/ensure-wasm.mjs index 385d4a49d6..15848c952b 100644 --- a/packages/xl-typst-compiler/scripts/ensure-wasm.mjs +++ b/packages/xl-typst-compiler/scripts/ensure-wasm.mjs @@ -4,8 +4,9 @@ // Vercel builds (whose `--filter '...'` graphs include this package) - // gets a correct wasm without a separate step. // -// - Fresh (pkg/.build-hash matches the hashed Rust inputs): exits silently. -// - Stale/missing: builds with the wasm-pack devDependency. Needs a Rust +// - Fresh verified pkg/: exits silently. +// - Stale/missing: restore the shared cache or an exact-hash CI artifact first. +// - Cache miss: builds with the wasm-pack devDependency. Needs a Rust // toolchain; rustup auto-provisions the pinned version + wasm32 target // from rust/rust-toolchain.toml. // - No toolchain: on CI and Vercel, rustup is bootstrapped automatically @@ -15,60 +16,25 @@ // seconds. On a dev machine it fails with instructions instead of // installing things. // -// `--force` skips the freshness check (the `build:wasm` script). +// `--force` skips every cache (the `build:wasm` script). import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; +import { mkdirSync, rmSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, relative } from "node:path"; -import { fileURLToPath } from "node:url"; +import { delimiter, join } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { buildWasm } from "./wasm-build.mjs"; +import { + cacheRoot, + downloadOutput, + inputHash, + installOutput, + packageDir, + storeOutput, + validateOutput, + writeManifest, +} from "./wasm-cache.mjs"; -const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); -const rustDir = join(packageDir, "rust"); const pkgDir = join(packageDir, "pkg"); -const hashFile = join(pkgDir, ".build-hash"); - -function listRustInputs() { - const files = [ - join(rustDir, "Cargo.toml"), - join(rustDir, "Cargo.lock"), - join(rustDir, "rust-toolchain.toml"), - ]; - function walk(dir) { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { - walk(path); - } else { - files.push(path); - } - } - } - walk(join(rustDir, "src")); - return files.sort(); -} - -function inputHash() { - const hash = createHash("sha256"); - // The wasm-pack version participates: it drives the generated JS glue. - hash.update( - JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) - .devDependencies["wasm-pack"], - ); - for (const file of listRustInputs()) { - // Cache identity must not depend on the checkout location (host runner, - // Playwright container, local checkout, and Vercel use different paths). - hash.update(relative(packageDir, file)); - hash.update(readFileSync(file)); - } - return hash.digest("hex"); -} function commandExists(command, env) { try { @@ -79,78 +45,125 @@ function commandExists(command, env) { } } -const force = process.argv.includes("--force"); -const currentHash = inputHash(); -if ( - !force && - existsSync(join(pkgDir, "blocknote_typst_wasm_bg.wasm")) && - existsSync(hashFile) && - readFileSync(hashFile, "utf8") === currentHash -) { - process.exit(0); -} +async function ensureWasm() { + const force = process.argv.includes("--force"); + // Publishing deliberately bypasses all reusable outputs, including local pkg/. + const sourceOnly = process.env.BLOCKNOTE_TYPST_SOURCE_ONLY === "1"; + const currentHash = inputHash(); + if (!force && !sourceOnly) { + if (validateOutput(pkgDir, currentHash)) { + return; + } + const cached = join(cacheRoot(), currentHash); + if (validateOutput(cached, currentHash)) { + installOutput(cached, pkgDir); + console.log("[ensure-wasm] restored shared WASM cache"); + return; + } + if (process.env.BLOCKNOTE_TYPST_DOWNLOAD !== "0") { + const result = await downloadOutput(currentHash, pkgDir); + if (!result.error) { + storeOutput(pkgDir, currentHash); + console.log( + "[ensure-wasm] downloaded matching WASM from GitHub Actions", + ); + return; + } + console.log(`[ensure-wasm] ${result.error}; building from source`); + } + } -const env = { ...process.env }; + const env = { ...process.env }; -// On Vercel, keep every Rust artifact under node_modules/.cache: it is -// persisted between builds, so only the first build on a fresh cache pays -// the full compile. Elsewhere rustup's default home is fine (and gets on -// PATH explicitly, since a fresh install can't modify the current shell). -if (process.env.VERCEL) { - const cacheDir = join(packageDir, "node_modules", ".cache", "rust"); - mkdirSync(cacheDir, { recursive: true }); - env.CARGO_HOME = join(cacheDir, "cargo"); - env.RUSTUP_HOME = join(cacheDir, "rustup"); - env.CARGO_TARGET_DIR = join(cacheDir, "target"); - env.PATH = `${join(env.CARGO_HOME, "bin")}:${env.PATH}`; -} else { - env.PATH = `${join(homedir(), ".cargo", "bin")}:${env.PATH}`; -} + // On Vercel, keep every Rust artifact under node_modules/.cache: it is + // persisted between builds, so only the first build on a fresh cache pays + // the full compile. Elsewhere rustup's default home is fine (and gets on + // PATH explicitly, since a fresh install can't modify the current shell). + if (process.env.VERCEL) { + const cacheDir = join(packageDir, "node_modules", ".cache", "rust"); + mkdirSync(cacheDir, { recursive: true }); + env.CARGO_HOME = join(cacheDir, "cargo"); + env.RUSTUP_HOME = join(cacheDir, "rustup"); + env.CARGO_TARGET_DIR = join(cacheDir, "target"); + } + // Windows commonly spells this key `Path`; spreading process.env loses its + // case-insensitive lookup behavior, so retain the original key. + const pathKey = + process.platform === "win32" + ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") || "PATH" + : "PATH"; + const cargoBin = join(env.CARGO_HOME || join(homedir(), ".cargo"), "bin"); + env[pathKey] = [cargoBin, env[pathKey]].filter(Boolean).join(delimiter); -if (!commandExists("cargo", env)) { - // CI covers GitHub Actions (whose ubuntu-24.04 images ship no Rust) and - // most other build services; only interactive dev machines get the - // do-it-yourself error. - if (!process.env.VERCEL && !process.env.CI) { - console.error( - "[ensure-wasm] pkg/ is missing or stale and no Rust toolchain was " + - "found. Install rustup (https://rustup.rs) - the pinned toolchain " + - "and wasm32 target auto-install from rust/rust-toolchain.toml - " + - "then re-run the build (or `npm run build:wasm` in " + - "packages/xl-typst-compiler).", + if (!commandExists("cargo", env)) { + // CI covers GitHub Actions (whose ubuntu-24.04 images ship no Rust) and + // most other build services. Windows needs its native rustup installer; + // interactive dev machines also install Rust themselves. + if ( + process.platform === "win32" || + (!process.env.VERCEL && !process.env.CI) + ) { + console.error( + "[ensure-wasm] pkg/ is missing or stale and no Rust toolchain was " + + "found. Install rustup (https://rustup.rs) - the pinned toolchain " + + "and wasm32 target auto-install from rust/rust-toolchain.toml - " + + "then re-run the build (or `vp run build:wasm` in " + + "packages/xl-typst-compiler).", + ); + process.exitCode = 1; + return; + } + console.log("[ensure-wasm] installing rustup (CI/Vercel build)…"); + execFileSync( + "sh", + [ + "-c", + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none --no-modify-path", + ], + { stdio: "inherit", env }, ); - process.exit(1); } - console.log("[ensure-wasm] installing rustup (CI/Vercel build)…"); - execFileSync( - "sh", - [ - "-c", - "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none --no-modify-path", - ], - { stdio: "inherit", env }, + + console.log( + "[ensure-wasm] building the Typst compiler wasm (rust/ -> pkg/)…", ); + buildWasm(packageDir, env); + if (inputHash() !== currentHash) { + throw new Error( + "Typst WASM inputs changed during compilation; rerun the build.", + ); + } + writeManifest(pkgDir, currentHash); + // A forced developer rebuild can seed other worktrees too; release builds + // intentionally do not read or write the shared developer cache. + if (!sourceOnly) { + storeOutput(pkgDir, currentHash); + } + console.log("[ensure-wasm] done."); } -console.log("[ensure-wasm] building the Typst compiler wasm (rust/ -> pkg/)…"); -execFileSync( - join(packageDir, "node_modules", ".bin", "wasm-pack"), - [ - "build", - ".", - "--target", - "web", - "--release", - "--out-dir", - "../pkg", - "--out-name", - "blocknote_typst_wasm", - ], - // cwd must be the crate directory: rustup resolves rust-toolchain.toml - // (the pinned toolchain + wasm32 target, auto-installed on first use) by - // walking up from the *working directory* - from packageDir a freshly - // bootstrapped rustup with no default toolchain finds nothing to run. - { stdio: "inherit", cwd: rustDir, env }, -); -writeFileSync(hashFile, currentHash); -console.log("[ensure-wasm] done."); +// Serialize builds in one checkout. The shared cache itself uses atomic +// publication, so builds in different worktrees don't block one another. +const lock = join(packageDir, "node_modules", ".typst-wasm.lock"); +const deadline = Date.now() + 10 * 60 * 1000; +while (true) { + try { + mkdirSync(lock); + break; + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + if (Date.now() > deadline) { + throw new Error( + `Timed out waiting for ${lock}; remove it if its build was interrupted.`, + ); + } + await setTimeout(250); + } +} +try { + await ensureWasm(); +} finally { + rmSync(lock, { recursive: true, force: true }); +} diff --git a/packages/xl-typst-compiler/scripts/wasm-build.mjs b/packages/xl-typst-compiler/scripts/wasm-build.mjs new file mode 100644 index 0000000000..4b10db964b --- /dev/null +++ b/packages/xl-typst-compiler/scripts/wasm-build.mjs @@ -0,0 +1,33 @@ +// The output-producing build contract. This entire file is automatically hashed +// alongside Rust inputs and the resolved wasm-pack version. Keep compilation +// settings here, not in the cache/download orchestrator. +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +export const outputFiles = [ + "blocknote_typst_wasm.js", + "blocknote_typst_wasm.d.ts", + "blocknote_typst_wasm_bg.wasm", + "blocknote_typst_wasm_bg.wasm.d.ts", +]; + +export function buildWasm(packageDir, env) { + execFileSync( + process.execPath, + [ + join(packageDir, "node_modules", "wasm-pack", "run.js"), + "build", + ".", + "--target", + "web", + "--release", + "--out-dir", + "../pkg", + "--out-name", + "blocknote_typst_wasm", + ], + // rustup resolves the pinned toolchain/target from the working directory, + // including on freshly bootstrapped installations with no default toolchain. + { stdio: "inherit", cwd: join(packageDir, "rust"), env }, + ); +} diff --git a/packages/xl-typst-compiler/scripts/wasm-cache.mjs b/packages/xl-typst-compiler/scripts/wasm-cache.mjs new file mode 100644 index 0000000000..da97e6308d --- /dev/null +++ b/packages/xl-typst-compiler/scripts/wasm-cache.mjs @@ -0,0 +1,430 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { FlateErrorCode, unzipSync } from "fflate"; +import { outputFiles } from "./wasm-build.mjs"; + +export { outputFiles }; + +export const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); +// v3 uses one checksum manifest; v2's redundant .build-hash is not accepted +// in archives. Separate keys avoid reusing immutable v2 CI/cache entries. +const cacheFiles = [...outputFiles, ".build-manifest.json"]; +const cacheNamespace = "typst-wasm-v3"; +const repository = "TypeCellOS/BlockNote"; +const maxBytes = 64 * 1024 * 1024; + +function sha256(data) { + return createHash("sha256").update(data).digest("hex"); +} + +export function inputHash(root = packageDir) { + const files = [ + "rust/Cargo.toml", + "rust/Cargo.lock", + "rust/rust-toolchain.toml", + "scripts/wasm-build.mjs", + ]; + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + walk(path); + } else { + files.push(relative(root, path).split("\\").join("/")); + } + } + } + walk(join(root, "rust/src")); + const version = JSON.parse( + readFileSync(join(root, "node_modules/wasm-pack/package.json"), "utf8"), + ).version; + return sha256( + JSON.stringify([ + cacheNamespace, + version, + ...files + .sort() + .map((file) => [file, sha256(readFileSync(join(root, file)))]), + ]), + ); +} + +export function artifactName(hash) { + return `${cacheNamespace}-${hash}`; +} + +export function cacheRoot() { + return ( + process.env.BLOCKNOTE_TYPST_CACHE_DIR || + join( + process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), + "blocknote", + cacheNamespace, + ) + ); +} + +export function writeManifest(dir, hash) { + const files = Object.fromEntries( + outputFiles.map((file) => [file, sha256(readFileSync(join(dir, file)))]), + ); + // A source build can reuse an old pkg/ directory. Retire its v2 metadata too. + rmSync(join(dir, ".build-hash"), { force: true }); + writeFileSync( + join(dir, ".build-manifest.json"), + JSON.stringify({ hash, files }), + ); +} + +function parseJson(text) { + try { + return { value: JSON.parse(text) }; + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + return { error: "invalid JSON" }; + } +} + +export function validateOutput(dir, hash) { + if ( + !cacheFiles.every((file) => { + const path = join(dir, file); + return existsSync(path) && lstatSync(path).isFile(); + }) + ) { + return false; + } + const parsed = parseJson( + readFileSync(join(dir, ".build-manifest.json"), "utf8"), + ); + const manifest = parsed.value; + return ( + !parsed.error && + manifest?.hash === hash && + outputFiles.every( + (file) => + manifest.files?.[file] === sha256(readFileSync(join(dir, file))), + ) + ); +} + +// Copy completely before publishing, and clean up even if copying fails. +function withStagedOutput(source, parent, publish) { + mkdirSync(parent, { recursive: true }); + const stage = mkdtempSync(join(parent, ".typst-stage-")); + try { + for (const file of cacheFiles) { + copyFileSync(join(source, file), join(stage, file)); + } + publish(stage); + } finally { + rmSync(stage, { recursive: true, force: true }); + } +} + +// Call under the per-package build lock. Never merge an incomplete download +// into the live package. +export function installOutput(source, destination) { + withStagedOutput(source, dirname(destination), (stage) => { + publishOutput(stage, destination); + }); +} + +function publishOutput(stage, destination) { + rmSync(destination, { recursive: true, force: true }); + renameSync(stage, destination); +} + +export function storeOutput(source, hash, root = cacheRoot()) { + const destination = join(root, hash); + if (validateOutput(destination, hash)) { + return; + } + withStagedOutput(source, root, (stage) => { + // Atomic publication; another worktree may publish this same key first. + if (existsSync(destination)) { + // Leave invalid entries alone: readers reject them and can still build. + return; + } + try { + renameSync(stage, destination); + } catch (error) { + if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) { + throw error; + } + } + }); +} + +function githubToken() { + if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN) { + return process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + } + const result = spawnSync( + "gh", + ["auth", "token", "--hostname", "github.com"], + { + encoding: "utf8", + timeout: 5000, + }, + ); + if ( + result.error && + result.error.code !== "ENOENT" && + result.error.code !== "ETIMEDOUT" + ) { + throw result.error; + } + return result.status === 0 ? result.stdout.trim() : undefined; +} + +async function request(url, token, fetcher) { + try { + const response = await fetcher(url, { + headers: token + ? { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + } + : {}, + redirect: "manual", + signal: AbortSignal.timeout(15000), + }); + return { response }; + } catch (error) { + if ( + !(error instanceof TypeError) && + !["TimeoutError", "AbortError"].includes(error.name) + ) { + throw error; + } + return { error: "GitHub request unavailable" }; + } +} + +async function jsonRequest(url, token, fetcher) { + const result = await request(url, token, fetcher); + if (result.error) { + return result; + } + if (!result.response.ok) { + return { error: `GitHub returned HTTP ${result.response.status}` }; + } + const body = await readBody(result.response); + return body.error ? body : parseJson(body.bytes.toString("utf8")); +} + +async function readBody(response) { + try { + const chunks = []; + let size = 0; + if (!response.body) { + return { error: "empty GitHub response" }; + } + for await (const chunk of response.body) { + size += chunk.length; + if (size > maxBytes) { + return { error: "GitHub response exceeds size limit" }; + } + chunks.push(chunk); + } + return { bytes: Buffer.concat(chunks) }; + } catch (error) { + if ( + !(error instanceof TypeError) && + !["TimeoutError", "AbortError"].includes(error.name) + ) { + throw error; + } + return { error: "GitHub download interrupted" }; + } +} + +export function trustedRun(run) { + return ( + run?.event === "push" && + run.head_branch === "main" && + run.head_repository?.full_name === repository && + run.repository?.full_name === repository && + run.path === ".github/workflows/build.yml" && + run.status === "completed" && + run.conclusion === "success" + ); +} + +// Extract individual allowlisted files to buffers, never archive paths. This +// also prevents zip traversal/symlinks from writing outside the staging dir. +function readZip(bytes) { + const seen = new Set(); + let size = 0; + let invalid = false; + try { + const files = unzipSync(bytes, { + filter(file) { + size += file.originalSize; + if ( + !cacheFiles.includes(file.name) || + seen.has(file.name) || + !Number.isSafeInteger(file.originalSize) || + file.originalSize < 0 || + size > maxBytes + ) { + invalid = true; + } + seen.add(file.name); + return !invalid; + }, + }); + return invalid || seen.size !== cacheFiles.length + ? { error: "unexpected artifact contents or size" } + : { files }; + } catch (error) { + if ( + !(error instanceof RangeError) && + !Object.values(FlateErrorCode).includes(error.code) + ) { + throw error; + } + return { error: "invalid artifact archive" }; + } +} + +export function unpackArtifact(bytes, dir, hash) { + const result = readZip(bytes); + if (result.error) { + return result; + } + for (const file of cacheFiles) { + writeFileSync(join(dir, file), result.files[file]); + } + return validateOutput(dir, hash) + ? { restored: true } + : { error: "artifact checksum mismatch" }; +} + +async function downloadArchive(base, id, token, fetcher) { + const archive = await request(`${base}/artifacts/${id}/zip`, token, fetcher); + if (archive.error) { + return archive; + } + const location = archive.response.headers.get("location"); + if ( + archive.response.status !== 302 || + !location || + !URL.canParse(location) || + new URL(location).protocol !== "https:" + ) { + return { error: "artifact download unavailable" }; + } + // Signed storage URL: never forward the GitHub token to another host. + const download = await request(location, undefined, fetcher); + if (download.error) { + return download; + } + return download.response.ok + ? readBody(download.response) + : { error: "artifact download unavailable" }; +} + +export async function downloadOutput( + hash, + destination, + { token = githubToken(), fetcher = fetch } = {}, +) { + if (!token) { + return { error: "no GitHub credentials (use gh auth login or GH_TOKEN)" }; + } + const deadline = AbortSignal.timeout(30000); + const fetchRequest = fetcher; + fetcher = (url, options) => + fetchRequest(url, { + ...options, + signal: AbortSignal.any([deadline, options.signal]), + }); + const base = `https://api.github.com/repos/${repository}/actions`; + const result = await jsonRequest( + `${base}/artifacts?name=${artifactName(hash)}&per_page=100`, + token, + fetcher, + ); + if (result.error) { + return result; + } + if (!Array.isArray(result.value?.artifacts)) { + return { error: "invalid GitHub artifact listing" }; + } + for (const artifact of result.value.artifacts) { + if ( + artifact?.name !== artifactName(hash) || + artifact.expired || + !Number.isSafeInteger(artifact.id) || + !Number.isSafeInteger(artifact.workflow_run?.id) + ) { + continue; + } + // Speculate on one candidate at a time. Bytes may arrive before trust is + // known, but nothing is extracted or installed until both checks pass. + const controller = new AbortController(); + const candidateFetch = (url, options) => + fetcher(url, { + ...options, + signal: AbortSignal.any([controller.signal, options.signal]), + }); + const [trusted, body] = await Promise.all([ + jsonRequest( + `${base}/runs/${artifact.workflow_run.id}`, + token, + candidateFetch, + ).then((run) => { + const trusted = !run.error && trustedRun(run.value); + if (!trusted) { + controller.abort(); + } + return trusted; + }), + downloadArchive(base, artifact.id, token, candidateFetch), + ]).finally(() => controller.abort()); + if (!trusted) { + continue; + } + if (body.error) { + return body; + } + // Extract beside pkg/ so verified files can be renamed into place without + // another full copy (and without crossing filesystem boundaries). + mkdirSync(dirname(destination), { recursive: true }); + const stage = mkdtempSync(join(dirname(destination), ".typst-stage-")); + try { + const unpacked = unpackArtifact(body.bytes, stage, hash); + if (unpacked.error) { + return unpacked; + } + publishOutput(stage, destination); + return { restored: true }; + } finally { + rmSync(stage, { recursive: true, force: true }); + } + } + return { error: "no matching artifact from a successful main build" }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + console.log(inputHash()); +} diff --git a/packages/xl-typst-compiler/scripts/wasm-cache.test.mjs b/packages/xl-typst-compiler/scripts/wasm-cache.test.mjs new file mode 100644 index 0000000000..965458435b --- /dev/null +++ b/packages/xl-typst-compiler/scripts/wasm-cache.test.mjs @@ -0,0 +1,562 @@ +import { execFile, execFileSync } from "node:child_process"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { zipSync } from "fflate"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + artifactName, + downloadOutput, + inputHash, + installOutput, + outputFiles, + packageDir, + storeOutput, + trustedRun, + unpackArtifact, + validateOutput, + writeManifest, +} from "./wasm-cache.mjs"; + +vi.mock("node:fs", { spy: true }); + +const temps = []; +function temp() { + const dir = mkdtempSync(join(tmpdir(), "typst-cache-test-")); + temps.push(dir); + return dir; +} +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + for (const dir of temps.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function fixture() { + const root = temp(); + for (const dir of ["rust/src", "scripts", "node_modules/wasm-pack", "bin"]) { + mkdirSync(join(root, dir), { recursive: true }); + } + cpSync( + join(packageDir, "node_modules/fflate"), + join(root, "node_modules/fflate"), + { recursive: true, dereference: true }, + ); + for (const file of ["Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]) { + writeFileSync(join(root, "rust", file), file); + } + writeFileSync(join(root, "rust/src/lib.rs"), "rust source"); + writeFileSync( + join(root, "node_modules/wasm-pack/package.json"), + '{"version":"0.15.0"}', + ); + for (const file of ["ensure-wasm.mjs", "wasm-cache.mjs", "wasm-build.mjs"]) { + copyFileSync( + join(packageDir, "scripts", file), + join(root, "scripts", file), + ); + } + return root; +} + +function output(dir, hash) { + mkdirSync(dir, { recursive: true }); + for (const file of outputFiles) { + writeFileSync(join(dir, file), `generated ${file}`); + } + writeManifest(dir, hash); +} + +function zipOutput(dir, extra = {}) { + return zipSync({ + ...Object.fromEntries( + [...outputFiles, ".build-manifest.json"].map((file) => [ + file, + readFileSync(join(dir, file)), + ]), + ), + ...extra, + }); +} + +const run = { + event: "push", + head_branch: "main", + head_repository: { full_name: "TypeCellOS/BlockNote" }, + repository: { full_name: "TypeCellOS/BlockNote" }, + path: ".github/workflows/build.yml", + status: "completed", + conclusion: "success", +}; + +describe("WASM fingerprint and verification", () => { + it("is checkout-independent and ignores TypeScript-only changes", () => { + const first = fixture(); + const second = fixture(); + expect(inputHash(first)).toBe(inputHash(second)); + writeFileSync(join(first, "wrapper.ts"), "changed TypeScript"); + expect(inputHash(first)).toBe(inputHash(second)); + }); + + it.each([ + "rust/src/lib.rs", + "rust/src/new.rs", + "rust/Cargo.toml", + "rust/Cargo.lock", + "rust/rust-toolchain.toml", + "scripts/wasm-build.mjs", + "node_modules/wasm-pack/package.json", + ])("invalidates after changing %s", (file) => { + const root = fixture(); + const before = inputHash(root); + writeFileSync( + join(root, file), + file.endsWith("package.json") ? '{"version":"0.16.0"}' : "changed", + ); + expect(inputHash(root)).not.toBe(before); + }); + + it.each(["scripts/ensure-wasm.mjs", "scripts/wasm-cache.mjs"])( + "preserves compilation identity after transport/orchestration changes to %s", + (file) => { + const root = fixture(); + const before = inputHash(root); + writeFileSync(join(root, file), "// changed cache transport or logging"); + expect(inputHash(root)).toBe(before); + }, + ); + + it("uses the checksum manifest as its only metadata", () => { + const dir = temp(); + writeFileSync(join(dir, ".build-hash"), "legacy v2 metadata"); + output(dir, "hash"); + expect(readdirSync(dir).sort()).toEqual( + [...outputFiles, ".build-manifest.json"].sort(), + ); + expect(validateOutput(dir, "hash")).toBe(true); + expect(unpackArtifact(zipOutput(dir), temp(), "hash")).toEqual({ + restored: true, + }); + expect( + unpackArtifact( + zipOutput(dir, { ".build-hash": Buffer.from("hash") }), + temp(), + "hash", + ).error, + ).toContain("contents"); + }); + + it("rejects missing, corrupt, stale, or malformed outputs", () => { + const dir = temp(); + expect(validateOutput(dir, "hash")).toBe(false); + output(dir, "hash"); + expect(validateOutput(dir, "hash")).toBe(true); + expect(validateOutput(dir, "other")).toBe(false); + writeFileSync(join(dir, outputFiles[0]), "corrupt"); + expect(validateOutput(dir, "hash")).toBe(false); + output(dir, "hash"); + writeFileSync(join(dir, ".build-manifest.json"), "broken json"); + expect(validateOutput(dir, "hash")).toBe(false); + }); + + it("shares a verified output without merging stale destination files", () => { + const source = temp(); + const cache = temp(); + const destination = temp(); + output(source, "hash"); + storeOutput(source, "hash", cache); + storeOutput(source, "hash", cache); + writeFileSync(join(destination, "stale.js"), "stale"); + installOutput(join(cache, "hash"), destination); + expect(validateOutput(destination, "hash")).toBe(true); + expect(existsSync(join(destination, "stale.js"))).toBe(false); + }); +}); + +describe("GitHub artifacts", () => { + it.each([true, false])( + "downloads while trust is pending, then gates installation (trusted: %s)", + async (trusted) => { + const source = temp(); + const destination = join(temp(), "pkg"); + output(source, "hash"); + output(destination, "previous"); + const zip = zipOutput(source); + const metadata = Promise.withResolvers(); + let storageSignal; + let storageStarted = false; + const pending = downloadOutput("hash", destination, { + token: "token", + fetcher: async (url, options) => { + if (url.includes("/artifacts?")) { + return Response.json({ + artifacts: [ + { name: artifactName("hash"), id: 1, workflow_run: { id: 2 } }, + ], + }); + } + if (url.endsWith("/runs/2")) { + return metadata.promise; + } + if (url.endsWith("/artifacts/1/zip")) { + return new Response(null, { + status: 302, + headers: { location: "https://storage.example/artifact" }, + }); + } + expect(url).toBe("https://storage.example/artifact"); + expect(options.headers.Authorization).toBeUndefined(); + storageStarted = true; + storageSignal = options.signal; + if (trusted) { + return new Response(zip); + } + return new Promise((resolve, reject) => { + options.signal.throwIfAborted(); + options.signal.addEventListener( + "abort", + () => reject(options.signal.reason), + { once: true }, + ); + }); + }, + }); + try { + // Drain the immediate mock HTTP work, without resolving producer trust. + await new Promise(setImmediate); + expect(storageStarted).toBe(true); + expect(validateOutput(destination, "previous")).toBe(true); + expect(readdirSync(dirname(destination))).toEqual(["pkg"]); + } finally { + metadata.resolve( + Response.json(trusted ? run : { ...run, event: "pull_request" }), + ); + const result = await pending; + expect(result).toEqual( + trusted + ? { restored: true } + : { error: "no matching artifact from a successful main build" }, + ); + } + expect(storageSignal.aborted).toBe(true); + expect(validateOutput(destination, trusted ? "hash" : "previous")).toBe( + true, + ); + }, + ); + + it.each([ + { event: "pull_request" }, + { head_branch: "feature" }, + { head_repository: { full_name: "untrusted/fork" } }, + { path: ".github/workflows/other.yml" }, + { status: "in_progress" }, + { conclusion: "failure" }, + ])("rejects an untrusted producer: %j", (change) => { + expect(trustedRun({ ...run, ...change })).toBe(false); + }); + + it.each([false, true])( + "verifies downloads before installation (corrupt: %s) without forwarding credentials to storage", + async (corrupt) => { + const source = temp(); + const parent = temp(); + const destination = join(parent, "nested", "pkg"); + output(destination, "previous"); + output(source, "hash"); + if (corrupt) { + writeFileSync(join(source, outputFiles[0]), "corrupt"); + } + const zip = zipOutput(source); + const calls = []; + const responses = [ + Response.json({ + artifacts: [ + { + name: artifactName("hash"), + id: 1, + expired: false, + workflow_run: { id: 2 }, + }, + ], + }), + Response.json(run), + new Response(null, { + status: 302, + headers: { location: "https://storage.example/artifact" }, + }), + new Response(zip), + ]; + vi.mocked(copyFileSync).mockClear(); + const result = await downloadOutput("hash", destination, { + token: "test-token", + fetcher: async (url, options) => { + calls.push({ url, options }); + return responses.shift(); + }, + }); + expect(result).toEqual( + corrupt ? { error: "artifact checksum mismatch" } : { restored: true }, + ); + expect(validateOutput(destination, corrupt ? "previous" : "hash")).toBe( + true, + ); + expect(readdirSync(dirname(destination))).toEqual(["pkg"]); + expect(calls[0].url).toContain(`name=${artifactName("hash")}`); + expect(calls[0].options.headers.Authorization).toBe("Bearer test-token"); + expect(calls[3].options.headers.Authorization).toBeUndefined(); + expect(vi.mocked(mkdtempSync).mock.calls.at(-1)[0]).toBe( + join(dirname(destination), ".typst-stage-"), + ); + expect(copyFileSync).not.toHaveBeenCalled(); + }, + ); + + it("reports expected credential, network, authentication, and expiration misses", async () => { + expect( + (await downloadOutput("hash", temp(), { token: "" })).error, + ).toContain("credentials"); + for (const fetcher of [ + async () => { + throw new TypeError("offline"); + }, + async () => new Response(null, { status: 401 }), + async () => Response.json({ artifacts: [] }), + async () => + Response.json({ + artifacts: [{ name: artifactName("hash"), expired: true }], + }), + ]) { + expect( + (await downloadOutput("hash", temp(), { token: "token", fetcher })) + .error, + ).toBeTruthy(); + } + }); + + it("does not swallow unexpected programming errors", async () => { + await expect( + downloadOutput("hash", temp(), { + token: "token", + fetcher: async () => { + throw new Error("bug"); + }, + }), + ).rejects.toThrow("bug"); + }); + + it("rejects corrupt archives and unexpected archive paths", () => { + const source = temp(); + output(source, "hash"); + writeFileSync(join(source, outputFiles[0]), "corrupt"); + expect(unpackArtifact(zipOutput(source), temp(), "hash").error).toContain( + "checksum", + ); + output(source, "hash"); + const zip = zipSync({ "../escape": new Uint8Array([1]) }); + expect(unpackArtifact(zip, temp(), "hash").error).toContain("contents"); + expect( + unpackArtifact(new Uint8Array([1, 2, 3]), temp(), "hash").error, + ).toContain("archive"); + }); + + it("extracts without system ZIP tools", () => { + const source = temp(); + const destination = temp(); + output(source, "hash"); + const zip = zipOutput(source); + vi.stubEnv("PATH", ""); + expect(unpackArtifact(zip, destination, "hash")).toEqual({ + restored: true, + }); + expect(validateOutput(destination, "hash")).toBe(true); + }); + + it.each(["duplicate entries", "oversized declared output"])( + "rejects %s before writing files", + (kind) => { + const source = temp(); + output(source, "hash"); + // fflate creates ZIPs without comments: the end record is the last 22 bytes, + // with the central-directory offset at +16. Mutate its metadata only. + const duplicate = Buffer.from( + zipOutput(source, { "blocknote_typst_wasm.jx": new Uint8Array([1]) }), + ); + const directory = duplicate.readUInt32LE(duplicate.length - 6); + const name = duplicate.indexOf("blocknote_typst_wasm.jx", directory); + duplicate.write("blocknote_typst_wasm.js", name); + const oversized = Buffer.from(zipOutput(source)); + oversized.writeUInt32LE( + 64 * 1024 * 1024 + 1, + oversized.readUInt32LE(oversized.length - 6) + 24, + ); + const zip = kind === "duplicate entries" ? duplicate : oversized; + const destination = temp(); + expect(unpackArtifact(zip, destination, "hash").error).toContain( + "contents", + ); + expect(readdirSync(destination)).toEqual([]); + }, + ); +}); + +describe("build entry point", () => { + function buildOptions(root, extraEnv = {}) { + return { + encoding: "utf8", + env: { + ...process.env, + BLOCKNOTE_TYPST_CACHE_DIR: join(root, "shared-cache"), + BLOCKNOTE_TYPST_DOWNLOAD: "0", + BLOCKNOTE_TYPST_SOURCE_ONLY: "0", + ...extraEnv, + }, + }; + } + + function build(root, extraEnv = {}, args = []) { + return execFileSync( + process.execPath, + [join(root, "scripts/ensure-wasm.mjs"), ...args], + buildOptions(root, extraEnv), + ); + } + + function fakeToolchain(root) { + writeFileSync(join(root, "bin/cargo"), "#!/bin/sh\nexit 0\n", { + mode: 0o755, + }); + writeFileSync( + join(root, "node_modules/wasm-pack/run.js"), + `#!${process.execPath} +const fs = require("node:fs"); +const path = require("node:path"); +const pkg = path.resolve("../pkg"); +setTimeout(() => { + fs.mkdirSync(pkg, { recursive: true }); + for (const file of ${JSON.stringify(outputFiles)}) { + fs.writeFileSync(path.join(pkg, file), "compiled output"); + } + fs.appendFileSync(path.resolve("../compilations"), "compiled\\n"); + fs.writeFileSync(path.resolve("../compiler-args.json"), JSON.stringify(process.argv.slice(2))); + console.log("compiled"); +}, 100); +`, + { mode: 0o755 }, + ); + return { PATH: `${join(root, "bin")}${delimiter}${process.env.PATH}` }; + } + + it("restores a fresh checkout without invoking a compiler, then reuses pkg locally", () => { + const root = fixture(); + const hash = inputHash(root); + output(join(root, "shared-cache", hash), hash); + expect(build(root)).toContain("restored shared WASM cache"); + expect(validateOutput(join(root, "pkg"), hash)).toBe(true); + expect(build(root)).toBe(""); + expect(existsSync(join(root, "node_modules/.typst-wasm.lock"))).toBe(false); + }); + + it("reports missing Rust on Windows CI without attempting the Unix installer", async () => { + const root = fixture(); + const preload = join(root, "windows.cjs"); + writeFileSync( + preload, + 'Object.defineProperty(process, "platform", { value: "win32" });', + ); + const execute = promisify(execFile); + await expect( + execute( + process.execPath, + ["--require", preload, join(root, "scripts/ensure-wasm.mjs")], + buildOptions(root, { + CI: "1", + PATH: "", + CARGO_HOME: join(root, "missing-rust"), + }), + ), + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Install rustup"), + }); + expect(existsSync(join(root, "node_modules/.typst-wasm.lock"))).toBe(false); + }); + + it("force and source-only bypass matching local and shared outputs", () => { + const root = fixture(); + const hash = inputHash(root); + output(join(root, "pkg"), hash); + output(join(root, "shared-cache", hash), hash); + // A fake toolchain proves compilation is reached without paying for Rust. + const env = fakeToolchain(root); + expect(build(root, env, ["--force"])).toContain("compiled"); + expect(build(root, { ...env, BLOCKNOTE_TYPST_SOURCE_ONLY: "1" })).toContain( + "compiled", + ); + }); + + it("executes changed settings from the automatically hashed build contract", () => { + const root = fixture(); + const before = inputHash(root); + const contract = join(root, "scripts/wasm-build.mjs"); + writeFileSync( + contract, + readFileSync(contract, "utf8").replace('"web"', '"no-modules"'), + ); + expect(inputHash(root)).not.toBe(before); + expect(build(root, fakeToolchain(root), ["--force"])).toContain("compiled"); + expect( + JSON.parse(readFileSync(join(root, "compiler-args.json"), "utf8")), + ).toEqual([ + "build", + ".", + "--target", + "no-modules", + "--release", + "--out-dir", + "../pkg", + "--out-name", + "blocknote_typst_wasm", + ]); + }); + + it("serializes cold builds and saves source output for another worktree", async () => { + const root = fixture(); + const env = fakeToolchain(root); + const execute = promisify(execFile); + const results = await Promise.all([ + execute( + process.execPath, + [join(root, "scripts/ensure-wasm.mjs")], + buildOptions(root, env), + ), + execute( + process.execPath, + [join(root, "scripts/ensure-wasm.mjs")], + buildOptions(root, env), + ), + ]); + expect(results.map((result) => result.stdout).join("")).toContain( + "compiled", + ); + expect(readFileSync(join(root, "compilations"), "utf8")).toBe("compiled\n"); + const other = fixture(); + expect( + build(other, { BLOCKNOTE_TYPST_CACHE_DIR: join(root, "shared-cache") }), + ).toContain("restored shared WASM cache"); + expect(validateOutput(join(other, "pkg"), inputHash(other))).toBe(true); + }); +}); diff --git a/packages/xl-typst-compiler/vite.config.ts b/packages/xl-typst-compiler/vite.config.ts index 3fd9d50266..80e4a49242 100644 --- a/packages/xl-typst-compiler/vite.config.ts +++ b/packages/xl-typst-compiler/vite.config.ts @@ -9,15 +9,25 @@ export default defineConfig( ({ run: { tasks: { + "build:wasm": { + command: "node scripts/ensure-wasm.mjs --force", + cache: false, + }, build: { - // ensure-wasm builds pkg/ from rust/ when missing or stale + // ensure-wasm restores or builds pkg/ when missing or stale // (content-hashed; see scripts/ensure-wasm.mjs) - so every // consumer of this task, including the Vercel builds, gets the - // wasm without a separate step. Requires a Rust toolchain - // locally (pinned in rust/rust-toolchain.toml). + // wasm without a separate step. Rust is needed locally only on + // a cache miss (pinned in rust/rust-toolchain.toml). command: "node scripts/ensure-wasm.mjs && tsc --project tsconfig.json --composite false --incremental false --rootDir . && vp build", cache: { + env: [ + "BLOCKNOTE_TYPST_CACHE_DIR", + "BLOCKNOTE_TYPST_DOWNLOAD", + "BLOCKNOTE_TYPST_SOURCE_ONLY", + "XDG_CACHE_HOME", + ], input: [ ...buildCacheInputs("packages/xl-typst-compiler"), { pattern: "!**/*.tsbuildinfo", base: "workspace" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c50b9ed84..864b18fc55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6428,6 +6428,9 @@ importers: '@types/node': specifier: 25.9.5 version: 25.9.5 + fflate: + specifier: ^0.8.3 + version: 0.8.3 rimraf: specifier: ^5.0.10 version: 5.0.10