commit 01b38309e81a66b3a93e79a6ec9bcfcc5064a242 Author: Mihaela Mihaljevic Date: Sun Jul 5 06:07:19 2026 +0200 pages: deploy the WASM benchmark site Built by scripts/build-wasm-site.sh; the loader fetches the gzipped module and decompresses client-side, and coi-serviceworker.js supplies COOP/COEP so SharedArrayBuffer works without server headers. diff --git a/.forgejo/workflows/rules-conformance.yml b/.forgejo/workflows/rules-conformance.yml new file mode 100644 index 0000000..a035237 --- /dev/null +++ b/.forgejo/workflows/rules-conformance.yml @@ -0,0 +1,24 @@ +# Managed by rules-swift (scripts/sync-rules.sh). Verifies this repo tracks the +# CURRENT shared-rules baseline. The repo's own .swiftformat/.swiftlint.yml are +# repo-specific (baseline + project rules) and are intentionally NOT byte-checked. +name: rules-conformance +on: + push: + pull_request: + workflow_dispatch: +jobs: + rules: + runs-on: macos + steps: + - uses: actions/checkout@v4 + - name: Rules baseline freshness (VERSION) + run: | + BASE=https://codeberg.org/Mihaela/rules-swift/raw/branch/main + canon=$(curl -fsSL "$BASE/VERSION" | tr -d '[:space:]') + mine=$(tr -d '[:space:]' < .rules-version 2>/dev/null || echo MISSING) + if [ "$mine" != "$canon" ]; then + echo "STALE: shared-rules baseline is v$canon, this repo tracks v$mine." + echo "Review the baseline diff, fold it into this repo's rules, then set .rules-version to $canon." + exit 1 + fi + echo "rules baseline FRESH at v$canon (repo-specific rules preserved, not checked)" diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..2bf3d63 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,16 @@ +name: Linux + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + container: swift:6.2 + steps: + - uses: actions/checkout@v6 + - name: Build and test + run: bash scripts/check.sh diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..c39e5c2 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,15 @@ +name: macOS + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - name: Build and test + run: bash scripts/check.sh diff --git a/.github/workflows/wasm-site.yml b/.github/workflows/wasm-site.yml new file mode 100644 index 0000000..53d53fa --- /dev/null +++ b/.github/workflows/wasm-site.yml @@ -0,0 +1,357 @@ +name: WASM Test Site + +on: + push: + branches: [main] + paths: + - "Package.swift" + - "Package.resolved" + - "Sources/PureYAMLStreaming/**" + - "Sources/PureYAMLStreamingWasmSmoke/**" + - "WasmSite/**" + - "scripts/build-wasm-site.sh" + - ".github/workflows/wasm-site.yml" + pull_request: + paths: + - "Package.swift" + - "Package.resolved" + - "Sources/PureYAMLStreaming/**" + - "Sources/PureYAMLStreamingWasmSmoke/**" + - "WasmSite/**" + - "scripts/build-wasm-site.sh" + - ".github/workflows/wasm-site.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pureyaml-streaming-wasm-site-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y binaryen gzip libcurl4-openssl-dev + + - name: Install swiftly + run: | + curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz + tar zxf swiftly.tar.gz + ./swiftly init --quiet-shell-followup --assume-yes --skip-install + + - name: Install Swift 6.3.2 and Wasm SDK + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + swiftly install --use 6.3.2 + swiftly run swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c +6.3.2 + + - name: Build WASM site + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + bash scripts/build-wasm-site.sh + mkdir -p site + cp WasmSite/index.html site/index.html + cp WasmSite/main.js site/main.js + cp WasmSite/coi-serviceworker.js site/coi-serviceworker.js + cp WasmSite/stream-worker.js site/stream-worker.js + cp WasmSite/style.css site/style.css + cp WasmSite/pureyaml-streaming-wasm-smoke.wasm.gz site/pureyaml-streaming-wasm-smoke.wasm.gz + cp -R WasmSite/vendor site/vendor + mkdir -p site/fixtures + python3 - <<'PY' + from pathlib import Path + fixture = Path("site/fixtures/generated-large.yaml") + payload = "x" * 1024 + with fixture.open("w", encoding="utf-8") as handle: + for index in range(3000): + handle.write("---\n") + handle.write(f"name: ci-doc-{index}\n") + handle.write(f"enabled: {'true' if index % 2 == 0 else 'false'}\n") + handle.write(f"payload: {payload}\n") + PY + ls -lh site + + - name: Install Playwright Chromium + run: | + mkdir -p .build/playwright-ci + cd .build/playwright-ci + npm init -y + npm install playwright-chromium + npx playwright install --with-deps chromium + + - name: Test site locally + run: | + python3 -m http.server 8766 --bind 127.0.0.1 --directory site & + server_pid=$! + trap 'kill "$server_pid"' EXIT + node <<'NODE' + const { chromium } = require('./.build/playwright-ci/node_modules/playwright-chromium'); + + async function gotoIsolated(page, url) { + await page.goto(url); + for (let attempt = 0; attempt < 6; attempt += 1) { + if (await page.evaluate(() => globalThis.crossOriginIsolated === true)) { + return; + } + await page.waitForTimeout(500); + await page.reload(); + } + throw new Error('Expected page to become crossOriginIsolated'); + } + + function parsedStdout(output) { + const stdoutMatch = output.match(/stdout:\n([\s\S]*?)\n\nstderr:/); + return JSON.parse(stdoutMatch?.[1] || '{}'); + } + + (async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await gotoIsolated(page, 'http://127.0.0.1:8766/?v=' + Date.now()); + const optionCount = await page.locator('#sampleSelect option').count(); + if (optionCount < 10) { + throw new Error(`Expected at least 10 public YAML samples, got ${optionCount}`); + } + if (await page.locator('#streamButton').isDisabled()) { + throw new Error('Expected true streaming button to be enabled'); + } + await page.fill('#urlInput', 'http://127.0.0.1:8766/fixtures/generated-large.yaml'); + await page.click('#runButton'); + await page.waitForFunction( + () => document.querySelector('#status')?.textContent === 'Passed', + null, + { timeout: 60000 } + ); + const output = await page.locator('#output').textContent(); + const bytes = await page.locator('#bytesMetric').textContent(); + const parse = await page.locator('#parseMetric').textContent(); + const throughput = await page.locator('#throughputMetric').textContent(); + const documents = await page.locator('#documentsMetric').textContent(); + const runLog = await page.locator('#runLog').textContent(); + const streamDocument = await page.locator('#streamDocument').textContent(); + const parsed = parsedStdout(output); + if (parsed.ok !== true) throw new Error(output); + if (parsed.transport !== 'SwiftWASIHTTPClient.HostHTTPClient') throw new Error(output); + if (parsed.httpStatusCode !== 200) throw new Error(output); + if (parsed.documentCount !== 3000) throw new Error(output); + if (parsed.firstName !== 'ci-doc-0') throw new Error(output); + if (parsed.lastName !== 'ci-doc-2999') throw new Error(output); + if (!Number.isFinite(parsed.parseMilliseconds) || parsed.parseMilliseconds <= 0) { + throw new Error(`Expected positive parseMilliseconds, got ${parsed.parseMilliseconds}`); + } + if (!Number.isFinite(parsed.chunkCount) || parsed.chunkCount <= 1) { + throw new Error(`Expected multiple Swift chunks, got ${parsed.chunkCount}`); + } + if (parsed.chunkSize !== 31) { + throw new Error(`Expected chunkSize 31, got ${parsed.chunkSize}`); + } + if (parsed.maxChunkByteCount > parsed.chunkSize) { + throw new Error(`Expected max chunk <= chunkSize, got ${parsed.maxChunkByteCount}`); + } + if (parsed.scannerDocumentSourceCount !== 3000) { + throw new Error(`Expected scanner to emit 3000 sources, got ${parsed.scannerDocumentSourceCount}`); + } + if (documents !== '3000') { + throw new Error(`Expected document metric 3000, got: ${documents}`); + } + if (bytes === '-' || parse === '-') { + throw new Error(`Expected benchmark metrics, got bytes=${bytes}, parse=${parse}`); + } + if (!throughput.includes('/s')) { + throw new Error(`Expected throughput metric, got: ${throughput}`); + } + if (!runLog.includes('Fetching YAML bytes') || !runLog.includes('SwiftWASIHTTPClient.HostHTTPClient') || !runLog.includes('Starting Swift parser.') || !runLog.includes('ChunkedUTF8Reader consumed') || !runLog.includes('Benchmark completed successfully.')) { + throw new Error(`Expected detailed run log, got: ${runLog}`); + } + if (!streamDocument.includes('name: ci-doc-0') || !streamDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected buffered document panel to show generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected buffered document panel syntax highlighting'); + } + await page.click('#copyRunLogButton'); + await page.waitForFunction( + () => document.querySelector('#copyRunLogButton')?.textContent === 'Copied', + null, + { timeout: 5000 } + ); + + await page.click('#streamButton'); + await page.waitForFunction( + () => document.querySelector('#status')?.textContent === 'Passed', + null, + { timeout: 60000 } + ); + const streamingOutput = await page.locator('#output').textContent(); + const streamingRunLog = await page.locator('#runLog').textContent(); + const streamingDocument = await page.locator('#streamDocument').textContent(); + const streamingParsed = parsedStdout(streamingOutput); + if (streamingParsed.ok !== true) throw new Error(streamingOutput); + if (streamingParsed.transport !== 'SharedArrayBuffer fd_read stdin') throw new Error(streamingOutput); + if (streamingParsed.httpStatusCode !== 200) throw new Error(streamingOutput); + if (streamingParsed.documentCount !== 3000) throw new Error(streamingOutput); + if (streamingParsed.firstName !== 'ci-doc-0') throw new Error(streamingOutput); + if (streamingParsed.lastName !== 'ci-doc-2999') throw new Error(streamingOutput); + if (!Number.isFinite(streamingParsed.stdinReadCount) || streamingParsed.stdinReadCount <= 1) { + throw new Error(`Expected multiple stdin reads, got ${streamingParsed.stdinReadCount}`); + } + if (streamingParsed.maxStdinReadByteCount > streamingParsed.chunkSize) { + throw new Error(`Expected max stdin read <= chunkSize, got ${streamingParsed.maxStdinReadByteCount}`); + } + if (!streamingRunLog.includes('Streaming HTTP 200 response body into SharedArrayBuffer') || !streamingRunLog.includes('Network chunk') || !streamingRunLog.includes('Worker fd_read calls') || !streamingRunLog.includes('SharedArrayBuffer fd_read stdin')) { + throw new Error(`Expected true streaming run log, got: ${streamingRunLog}`); + } + if (!streamingDocument.includes('name: ci-doc-0') || !streamingDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected streaming document panel to append generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected streaming document panel syntax highlighting'); + } + await browser.close(); + })(); + NODE + + - name: Configure Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + - name: Install Playwright Chromium + run: | + mkdir -p .build/playwright-live + cd .build/playwright-live + npm init -y + npm install playwright-chromium + npx playwright install --with-deps chromium + + - name: Validate live Pages benchmark + env: + PAGE_URL: ${{ steps.deployment.outputs.page_url }} + run: | + node <<'NODE' + const { chromium } = require('./.build/playwright-live/node_modules/playwright-chromium'); + + async function gotoIsolated(page, url) { + await page.goto(url); + for (let attempt = 0; attempt < 6; attempt += 1) { + if (await page.evaluate(() => globalThis.crossOriginIsolated === true)) { + return; + } + await page.waitForTimeout(500); + await page.reload(); + } + throw new Error('Expected live page to become crossOriginIsolated'); + } + + function parsedStdout(output) { + const stdoutMatch = output.match(/stdout:\n([\s\S]*?)\n\nstderr:/); + return JSON.parse(stdoutMatch?.[1] || '{}'); + } + + (async () => { + const pageURL = process.env.PAGE_URL; + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await gotoIsolated(page, pageURL + '?v=' + Date.now()); + await page.fill('#urlInput', new URL('fixtures/generated-large.yaml', pageURL).href); + await page.click('#runButton'); + await page.waitForFunction( + () => document.querySelector('#status')?.textContent === 'Passed', + null, + { timeout: 60000 } + ); + const output = await page.locator('#output').textContent(); + const throughput = await page.locator('#throughputMetric').textContent(); + const runLog = await page.locator('#runLog').textContent(); + const streamDocument = await page.locator('#streamDocument').textContent(); + const parsed = parsedStdout(output); + if (parsed.transport !== 'SwiftWASIHTTPClient.HostHTTPClient') throw new Error(output); + if (parsed.httpStatusCode !== 200) throw new Error(output); + if (parsed.documentCount !== 3000) throw new Error(output); + if (parsed.firstName !== 'ci-doc-0') throw new Error(output); + if (parsed.lastName !== 'ci-doc-2999') throw new Error(output); + if (!Number.isFinite(parsed.parseMilliseconds) || parsed.parseMilliseconds <= 0) { + throw new Error(`Expected positive parseMilliseconds, got ${parsed.parseMilliseconds}`); + } + if (!Number.isFinite(parsed.chunkCount) || parsed.chunkCount <= 1) { + throw new Error(`Expected multiple Swift chunks, got ${parsed.chunkCount}`); + } + if (parsed.chunkSize !== 31) { + throw new Error(`Expected chunkSize 31, got ${parsed.chunkSize}`); + } + if (parsed.maxChunkByteCount > parsed.chunkSize) { + throw new Error(`Expected max chunk <= chunkSize, got ${parsed.maxChunkByteCount}`); + } + if (parsed.scannerDocumentSourceCount !== 3000) { + throw new Error(`Expected scanner to emit 3000 sources, got ${parsed.scannerDocumentSourceCount}`); + } + if (!throughput.includes('/s')) { + throw new Error(`Expected live throughput metric, got: ${throughput}`); + } + if (!runLog.includes('Fetching YAML bytes') || !runLog.includes('SwiftWASIHTTPClient.HostHTTPClient') || !runLog.includes('Starting Swift parser.') || !runLog.includes('ChunkedUTF8Reader consumed') || !runLog.includes('Benchmark completed successfully.')) { + throw new Error(`Expected live detailed run log, got: ${runLog}`); + } + if (!streamDocument.includes('name: ci-doc-0') || !streamDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected live buffered document panel to show generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected live buffered document panel syntax highlighting'); + } + await page.click('#streamButton'); + await page.waitForFunction( + () => document.querySelector('#status')?.textContent === 'Passed', + null, + { timeout: 60000 } + ); + const streamingOutput = await page.locator('#output').textContent(); + const streamingRunLog = await page.locator('#runLog').textContent(); + const streamingDocument = await page.locator('#streamDocument').textContent(); + const streamingParsed = parsedStdout(streamingOutput); + if (streamingParsed.transport !== 'SharedArrayBuffer fd_read stdin') throw new Error(streamingOutput); + if (streamingParsed.httpStatusCode !== 200) throw new Error(streamingOutput); + if (streamingParsed.documentCount !== 3000) throw new Error(streamingOutput); + if (streamingParsed.firstName !== 'ci-doc-0') throw new Error(streamingOutput); + if (streamingParsed.lastName !== 'ci-doc-2999') throw new Error(streamingOutput); + if (!Number.isFinite(streamingParsed.stdinReadCount) || streamingParsed.stdinReadCount <= 1) { + throw new Error(`Expected multiple live stdin reads, got ${streamingParsed.stdinReadCount}`); + } + if (!streamingRunLog.includes('Streaming HTTP 200 response body into SharedArrayBuffer') || !streamingRunLog.includes('Network chunk') || !streamingRunLog.includes('Worker fd_read calls') || !streamingRunLog.includes('SharedArrayBuffer fd_read stdin')) { + throw new Error(`Expected live true streaming run log, got: ${streamingRunLog}`); + } + if (!streamingDocument.includes('name: ci-doc-0') || !streamingDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected live streaming document panel to append generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected live streaming document panel syntax highlighting'); + } + await browser.close(); + })(); + NODE diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 0000000..17a1d74 --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,37 @@ +name: WASM + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install -y binaryen gzip libcurl4-openssl-dev + - name: Install swiftly + run: | + curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz + tar zxf swiftly.tar.gz + ./swiftly init --quiet-shell-followup --assume-yes --skip-install + - name: Install Swift 6.3.2 and Wasm SDK + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + swiftly install --use 6.3.2 + swiftly run swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c +6.3.2 + - name: Build WASM benchmark site + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + bash scripts/build-wasm-site.sh + - name: Upload WASM site artifact + uses: actions/upload-artifact@v6 + with: + name: pureyaml-streaming-wasm-site + path: WasmSite + retention-days: 7 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..c1e5047 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,24 @@ +name: Windows + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v6 + - name: Install Swift + uses: compnerd/gha-setup-swift@main + with: + swift-version: swift-6.3.2-release + swift-build: 6.3.2-RELEASE + - name: Build + shell: pwsh + run: swift build + - name: Test + shell: pwsh + run: swift test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..361a693 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +.DS_Store +WasmSite/*.wasm +WasmSite/*.wasm.gz +WasmSite/vendor/ diff --git a/.rules-version b/.rules-version new file mode 100644 index 0000000..45a1b3f --- /dev/null +++ b/.rules-version @@ -0,0 +1 @@ +1.1.2 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..4445813 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,12 @@ +# SwiftFormat configuration. https://github.com/nicklockwood/SwiftFormat +# This is a starting point; adjust to your project, but keep it committed so +# every clone and CI run formats identically. + +--swiftversion 6.0 +--indent 4 +--maxwidth 180 +--commas always +--self remove +--importgrouping testable-bottom +--trailingclosures +--exclude .build,Packages/.build,.git diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..b7499b4 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,31 @@ +# SwiftLint configuration. https://github.com/realm/SwiftLint +# A starting point; tune to your project, but keep it committed and run it with +# --strict so warnings cannot accumulate. + +disabled_rules: + - todo + +opt_in_rules: + - empty_count + - explicit_init + - first_where + - force_unwrapping + - sorted_first_last + - unused_import + +line_length: + warning: 180 + error: 220 + ignores_comments: true + ignores_urls: true + +identifier_name: + min_length: 2 + +type_name: + min_length: 2 + +excluded: + - .build + - Packages/.build + - .git diff --git a/coi-serviceworker.js b/coi-serviceworker.js new file mode 100644 index 0000000..d7fb18f --- /dev/null +++ b/coi-serviceworker.js @@ -0,0 +1,55 @@ +(() => { + const coop = "same-origin"; + const coep = "require-corp"; + const reloadKey = "pureyamlStreamingCOIReloadedV3"; + + if (typeof Window !== "undefined" && self instanceof Window) { + if (window.crossOriginIsolated || !("serviceWorker" in navigator) || !window.isSecureContext) { + return; + } + + const reloadForIsolation = () => { + if (!window.crossOriginIsolated && sessionStorage.getItem(reloadKey) !== "1") { + sessionStorage.setItem(reloadKey, "1"); + window.location.reload(); + } + }; + + navigator.serviceWorker.addEventListener("controllerchange", reloadForIsolation); + + navigator.serviceWorker.register("./coi-serviceworker.js").then(async (registration) => { + await registration.update(); + await navigator.serviceWorker.ready; + reloadForIsolation(); + }).catch((error) => { + console.warn("Unable to register cross-origin isolation service worker", error); + }); + return; + } + + self.addEventListener("install", () => { + self.skipWaiting(); + }); + + self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); + }); + + self.addEventListener("fetch", (event) => { + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { + return; + } + + event.respondWith((async () => { + const response = await fetch(event.request); + const headers = new Headers(response.headers); + headers.set("Cross-Origin-Opener-Policy", coop); + headers.set("Cross-Origin-Embedder-Policy", coep); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + })()); + }); +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..0b0c802 --- /dev/null +++ b/index.html @@ -0,0 +1,106 @@ + + + + + + PureYAMLStreaming WASM Benchmark + + + + +
+
+

PureYAMLStreaming

+

Live WASM YAML Benchmark

+

+ Pick a public real-world YAML file. The browser fetches it, feeds the + bytes to the Swift WASI executable through stdin, and reports + Swift-measured document parse timing live. The Run Log distinguishes + browser buffering from Swift-side chunked scanning. +

+ + + + + + +
Idle
+
+ +
+

Streaming Capability

+
+
+
Isolation
+
-
+
+
+
SharedArrayBuffer
+
-
+
+
+
Worker
+
-
+
+
+
Mode
+
-
+
+
+
+ +
+

Timing

+
+
+
Bytes
+
-
+
+
+
Fetch
+
-
+
+
+
Swift parse
+
-
+
+
+
Throughput
+
-
+
+
+
Documents
+
-
+
+
+
+ +
+
+

Run Log

+ +
+
+
Waiting for benchmark run.
+
+
+ +
+

Document Stream

+

+ In true streaming mode this panel appends decoded YAML chunks as the + browser receives them. In buffered mode it shows the fetched body after + the browser buffer is complete. +

+
Waiting for YAML bytes.
+
+ +
+

Output

+
Pick a YAML file and choose a benchmark mode.
+
+
+ + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..10e265d --- /dev/null +++ b/main.js @@ -0,0 +1,747 @@ +import { + ConsoleStdout, + File, + OpenFile, + WASI, +} from "./vendor/browser_wasi_shim/index.js"; + +const runButton = document.querySelector("#runButton"); +const streamButton = document.querySelector("#streamButton"); +const sampleSelect = document.querySelector("#sampleSelect"); +const urlInput = document.querySelector("#urlInput"); +const statusElement = document.querySelector("#status"); +const outputElement = document.querySelector("#output"); +const runLogElement = document.querySelector("#runLog"); +const copyRunLogButton = document.querySelector("#copyRunLogButton"); +const streamDocumentElement = document.querySelector("#streamDocument"); +const isolationMetric = document.querySelector("#isolationMetric"); +const sabMetric = document.querySelector("#sabMetric"); +const workerMetric = document.querySelector("#workerMetric"); +const modeMetric = document.querySelector("#modeMetric"); +const bytesMetric = document.querySelector("#bytesMetric"); +const fetchMetric = document.querySelector("#fetchMetric"); +const parseMetric = document.querySelector("#parseMetric"); +const throughputMetric = document.querySelector("#throughputMetric"); +const documentsMetric = document.querySelector("#documentsMetric"); +const params = new URLSearchParams(location.search); +const wasmURL = params.get("wasm") || "./pureyaml-streaming-wasm-smoke.wasm.gz"; +let modulePromise = null; +let runStartedAt = performance.now(); +const streamControlIndex = { + read: 0, + write: 1, + available: 2, + closed: 3, + error: 4, + sequence: 5, + totalWritten: 6, + readCalls: 7, + waitCount: 8, +}; +const streamControlCount = 16; +const streamBufferCapacity = 1024 * 1024; +const documentRenderBudget = 64 * 1024; +let documentRenderQueue = []; +let documentRenderScheduled = false; +let documentRenderResolvers = []; + +const samples = [ + { + name: "GitHub REST API OpenAPI (8.39 MiB)", + url: "https://api.apis.guru/v2/specs/github.com/1.1.4/openapi.yaml", + }, + { + name: "Loket.nl API OpenAPI (10.62 MiB)", + url: "https://api.apis.guru/v2/specs/loket.nl/V2/openapi.yaml", + }, + { + name: "Zuora Billing API OpenAPI (6.46 MiB)", + url: "https://api.apis.guru/v2/specs/zuora.com/2021-08-20/openapi.yaml", + }, + { + name: "Amazon EC2 OpenAPI (5.36 MiB)", + url: "https://api.apis.guru/v2/specs/amazonaws.com/ec2/2016-11-15/openapi.yaml", + }, + { + name: "Zoom API OpenAPI (4.80 MiB)", + url: "https://api.apis.guru/v2/specs/zoom.us/2.0.0/openapi.yaml", + }, + { + name: "UniCourt Enterprise APIs OpenAPI (4.29 MiB)", + url: "https://api.apis.guru/v2/specs/unicourt.com/1.0.0/openapi.yaml", + }, + { + name: "Kubernetes Swagger (4.26 MiB)", + url: "https://api.apis.guru/v2/specs/kubernetes.io/unversioned/swagger.yaml", + }, + { + name: "Autotask PSA Swagger (3.90 MiB)", + url: "https://api.apis.guru/v2/specs/autotask.net/v1/swagger.yaml", + }, + { + name: "Google Document AI Warehouse OpenAPI (3.61 MiB)", + url: "https://api.apis.guru/v2/specs/googleapis.com/contentwarehouse/v1/openapi.yaml", + }, + { + name: "Stripe API OpenAPI (3.48 MiB)", + url: "https://api.apis.guru/v2/specs/stripe.com/2022-11-15/openapi.yaml", + }, + { + name: "Google Compute Engine OpenAPI (3.32 MiB)", + url: "https://api.apis.guru/v2/specs/googleapis.com/compute/v1/openapi.yaml", + }, + { + name: "DocuSign REST API OpenAPI (3.14 MiB)", + url: "https://api.apis.guru/v2/specs/docusign.net/v2.1/openapi.yaml", + }, +]; + +for (const sample of samples) { + const option = document.createElement("option"); + option.value = sample.url; + option.textContent = sample.name; + sampleSelect.append(option); +} +urlInput.value = samples[0].url; + +runButton.addEventListener("click", () => { + runBenchmark().catch((error) => { + renderFailure(error); + }); +}); + +streamButton.addEventListener("click", () => { + runStreamingBenchmark().catch((error) => { + renderFailure(error); + }); +}); + +copyRunLogButton.addEventListener("click", () => { + copyRunLog().catch((error) => { + logStep(`Unable to copy run log: ${String(error?.message || error)}`, "error"); + }); +}); + +sampleSelect.addEventListener("change", () => { + urlInput.value = sampleSelect.value; +}); + +updateStreamingCapabilities(); + +async function runBenchmark() { + const sourceURL = urlInput.value.trim(); + if (!sourceURL) { + throw new Error("Choose or enter a YAML URL first."); + } + + runButton.disabled = true; + streamButton.disabled = true; + statusElement.className = "status idle"; + statusElement.textContent = "Preparing..."; + outputElement.textContent = ""; + resetMetrics(); + resetLog(); + resetStreamDocument("Waiting for buffered YAML fetch to complete."); + logStep("Queued benchmark run."); + logStep(`Selected source: ${sourceURL}`); + + logStep(`Loading WASM module: ${wasmURL}`); + const moduleLoadStart = performance.now(); + const module = await loadWasmModule(wasmURL); + const moduleLoadMS = performance.now() - moduleLoadStart; + logStep(`WASM module ready in ${formatDuration(moduleLoadMS)}.`); + + statusElement.textContent = "Fetching YAML..."; + logStep("Fetching YAML bytes in the browser. This fetch is buffered before WASI starts."); + const fetchStart = performance.now(); + const response = await fetch(sourceURL, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to fetch YAML: HTTP ${response.status}`); + } + const yamlBytes = new Uint8Array(await response.arrayBuffer()); + const fetchMS = performance.now() - fetchStart; + replaceStreamDocument(decodeYAMLBytes(yamlBytes)); + await waitForDocumentFlush(); + await nextPaint(); + logStep(`Fetched HTTP ${response.status}, ${formatBytes(yamlBytes.byteLength)} in ${formatDuration(fetchMS)}.`); + logStep("Handing buffered response to SwiftWASIHTTPClient.HostHTTPClient through WASI stdin."); + + logStep("Preparing WASI file descriptors."); + let stdoutText = ""; + let stderrText = ""; + const fds = [ + new OpenFile(new File(yamlBytes)), + ConsoleStdout.lineBuffered((line) => { + stdoutText += `${line}\n`; + }), + ConsoleStdout.lineBuffered((line) => { + stderrText += `${line}\n`; + }), + ]; + logStep("Creating WASI instance."); + const wasi = new WASI(["pureyaml-streaming-wasm-smoke", sourceURL, String(response.status)], [], fds); + statusElement.textContent = "Running Swift parser..."; + logStep("Starting Swift parser. The browser tab can pause while WebAssembly is executing."); + await nextPaint(); + const parseStart = performance.now(); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + logStep("WebAssembly instance created; entering WASI _start."); + await nextPaint(); + wasi.start(instance); + const wallClockParseMS = performance.now() - parseStart; + logStep(`WASI process returned in ${formatDuration(wallClockParseMS)}.`); + + logStep("Parsing Swift JSON result from stdout."); + const parsed = parseSmokeOutput(stdoutText); + const ok = parsed?.ok === true; + if (!Number.isFinite(parsed?.parseMilliseconds)) { + throw new Error("WASM output did not include a finite Swift parseMilliseconds value."); + } + const parseMS = parsed.parseMilliseconds; + const throughput = parseMS > 0 ? yamlBytes.byteLength / (parseMS / 1000) : 0; + if (!Number.isFinite(parsed?.chunkCount) || !Number.isFinite(parsed?.chunkSize)) { + throw new Error("WASM output did not include finite Swift chunk instrumentation."); + } + logStep(`Swift transport: ${parsed.transport || "unknown"}; HTTP status: ${parsed.httpStatusCode ?? "n/a"}.`); + logStep(`Swift parse reported ${formatDuration(parseMS)}.`); + logStep( + `Swift ChunkedUTF8Reader consumed ${parsed.chunkCount} chunk(s) at chunkSize ${formatBytes(parsed.chunkSize)}.`, + ); + logStep(`Largest Swift chunk observed: ${formatBytes(parsed.maxChunkByteCount ?? 0)}.`); + logStep(`Document scanner emitted ${parsed.scannerDocumentSourceCount ?? 0} document source(s).`); + logStep(`Parsed ${parsed.documentCount ?? 0} document(s).`); + logStep(`Throughput: ${formatBytes(throughput)}/s.`); + + statusElement.className = ok ? "status pass" : "status fail"; + statusElement.textContent = ok ? "Passed" : "Failed"; + bytesMetric.textContent = formatBytes(yamlBytes.byteLength); + fetchMetric.textContent = formatDuration(fetchMS); + parseMetric.textContent = formatDuration(parseMS); + throughputMetric.textContent = `${formatBytes(throughput)}/s`; + documentsMetric.textContent = parsed?.documentCount ?? "-"; + outputElement.textContent = [ + `url: ${sourceURL}`, + `moduleLoad: ${formatDuration(moduleLoadMS)}`, + `fetch: ${formatDuration(fetchMS)}`, + `swiftParse: ${formatDuration(parseMS)}`, + `processWallClock: ${formatDuration(wallClockParseMS)}`, + `throughput: ${formatBytes(throughput)}/s`, + "", + "stdout:", + stdoutText || "(empty)", + "", + "stderr:", + stderrText || "(empty)", + ].join("\n"); + logStep(ok ? "Benchmark completed successfully." : "Benchmark finished with parser failure."); + resetButtons(); +} + +async function runStreamingBenchmark() { + const sourceURL = urlInput.value.trim(); + if (!sourceURL) { + throw new Error("Choose or enter a YAML URL first."); + } + if (!streamingSupported()) { + throw new Error("True streaming mode requires cross-origin isolation, SharedArrayBuffer, Worker, and Atomics.wait."); + } + + runButton.disabled = true; + streamButton.disabled = true; + statusElement.className = "status idle"; + statusElement.textContent = "Streaming..."; + outputElement.textContent = ""; + resetMetrics(); + resetLog(); + resetStreamDocument("Waiting for streaming YAML chunks."); + logStep("Queued true browser-to-WASI streaming run."); + logStep(`Selected source: ${sourceURL}`); + + const sharedBuffer = new SharedArrayBuffer( + streamControlCount * Int32Array.BYTES_PER_ELEMENT + streamBufferCapacity, + ); + const control = new Int32Array(sharedBuffer, 0, streamControlCount); + const bytes = new Uint8Array(sharedBuffer, streamControlCount * Int32Array.BYTES_PER_ELEMENT, streamBufferCapacity); + let worker = null; + let stdoutText = ""; + let stderrText = ""; + let workerDone; + const workerDonePromise = new Promise((resolve, reject) => { + workerDone = { resolve, reject }; + }); + + logStep("Starting Worker-owned WASI runtime."); + const fetchStart = performance.now(); + const response = await fetch(sourceURL, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to fetch YAML: HTTP ${response.status}`); + } + if (!response.body) { + throw new Error("This browser did not expose a ReadableStream response body."); + } + + worker = new Worker("./stream-worker.js", { type: "module" }); + worker.addEventListener("message", (event) => { + const message = event.data || {}; + if (message.type === "log") { + logStep(message.message); + } else if (message.type === "stdout") { + stdoutText += `${message.line}\n`; + } else if (message.type === "stderr") { + stderrText += `${message.line}\n`; + } else if (message.type === "done") { + stdoutText = message.stdoutText; + stderrText = message.stderrText; + logStep(`Worker fd_read calls: ${message.readCalls}; ring-buffer waits: ${message.waitCount}.`); + workerDone.resolve(message); + } else if (message.type === "error") { + workerDone.reject(new Error(message.message)); + } + }); + + worker.postMessage({ + type: "start", + wasmURL, + sourceURL, + statusCode: response.status, + sharedBuffer, + capacity: streamBufferCapacity, + }); + + logStep(`Streaming HTTP ${response.status} response body into SharedArrayBuffer.`); + const reader = response.body.getReader(); + const documentDecoder = new TextDecoder(); + let networkChunkCount = 0; + let fetchedByteCount = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + networkChunkCount += 1; + fetchedByteCount += value.byteLength; + appendStreamDocumentChunk(documentDecoder.decode(value, { stream: true })); + await writeToRingBuffer(value, control, bytes); + logStep(`Network chunk ${networkChunkCount}: wrote ${formatBytes(value.byteLength)} to ring buffer.`); + } + } catch (error) { + Atomics.store(control, streamControlIndex.error, 1); + Atomics.store(control, streamControlIndex.closed, 1); + wakeRingBuffer(control); + worker?.terminate(); + throw error; + } + appendStreamDocumentChunk(documentDecoder.decode()); + await waitForDocumentFlush(); + Atomics.store(control, streamControlIndex.closed, 1); + wakeRingBuffer(control); + const fetchMS = performance.now() - fetchStart; + logStep(`Network stream finished: ${networkChunkCount} chunk(s), ${formatBytes(fetchedByteCount)} in ${formatDuration(fetchMS)}.`); + + let workerResult; + try { + workerResult = await workerDonePromise; + } catch (error) { + worker?.terminate(); + throw error; + } + worker?.terminate(); + renderBenchmarkResult({ + sourceURL, + moduleLoadMS: workerResult.moduleLoadMS, + fetchMS, + wallClockParseMS: workerResult.processWallClockMS, + stdoutText, + stderrText, + inputByteCount: fetchedByteCount, + workerResult, + modeLabel: "true streaming", + }); +} + +function renderBenchmarkResult({ + sourceURL, + moduleLoadMS, + fetchMS, + wallClockParseMS, + stdoutText, + stderrText, + inputByteCount, + modeLabel, +}) { + logStep("Parsing Swift JSON result from stdout."); + const parsed = parseSmokeOutput(stdoutText); + const ok = parsed?.ok === true; + if (!Number.isFinite(parsed?.parseMilliseconds)) { + throw new Error("WASM output did not include a finite Swift parseMilliseconds value."); + } + const parseMS = parsed.parseMilliseconds; + const throughput = parseMS > 0 ? inputByteCount / (parseMS / 1000) : 0; + if (!Number.isFinite(parsed?.chunkCount) || !Number.isFinite(parsed?.chunkSize)) { + throw new Error("WASM output did not include finite Swift chunk instrumentation."); + } + logStep(`Swift transport: ${parsed.transport || "unknown"}; HTTP status: ${parsed.httpStatusCode ?? "n/a"}.`); + logStep(`Swift parse reported ${formatDuration(parseMS)}.`); + logStep( + `Swift ChunkedUTF8Reader consumed ${parsed.chunkCount} chunk(s) at chunkSize ${formatBytes(parsed.chunkSize)}.`, + ); + logStep(`Largest Swift chunk observed: ${formatBytes(parsed.maxChunkByteCount ?? 0)}.`); + logStep(`Document scanner emitted ${parsed.scannerDocumentSourceCount ?? 0} document source(s).`); + logStep(`Swift stdin read calls: ${parsed.stdinReadCount ?? "n/a"}; max stdin read: ${formatBytes(parsed.maxStdinReadByteCount ?? 0)}.`); + logStep(`Parsed ${parsed.documentCount ?? 0} document(s).`); + logStep(`Throughput: ${formatBytes(throughput)}/s.`); + + statusElement.className = ok ? "status pass" : "status fail"; + statusElement.textContent = ok ? "Passed" : "Failed"; + bytesMetric.textContent = formatBytes(inputByteCount); + fetchMetric.textContent = formatDuration(fetchMS); + parseMetric.textContent = formatDuration(parseMS); + throughputMetric.textContent = `${formatBytes(throughput)}/s`; + documentsMetric.textContent = parsed?.documentCount ?? "-"; + outputElement.textContent = [ + `url: ${sourceURL}`, + `mode: ${modeLabel}`, + `moduleLoad: ${formatDuration(moduleLoadMS)}`, + `fetch: ${formatDuration(fetchMS)}`, + `swiftParse: ${formatDuration(parseMS)}`, + `processWallClock: ${formatDuration(wallClockParseMS)}`, + `throughput: ${formatBytes(throughput)}/s`, + "", + "stdout:", + stdoutText || "(empty)", + "", + "stderr:", + stderrText || "(empty)", + ].join("\n"); + logStep(ok ? "Benchmark completed successfully." : "Benchmark finished with parser failure."); + resetButtons(); +} + +async function writeToRingBuffer(chunk, control, bytes) { + let offset = 0; + while (offset < chunk.byteLength) { + const available = Atomics.load(control, streamControlIndex.available); + if (available >= streamBufferCapacity) { + logStep("Ring buffer full; fetch pump waiting for Worker fd_read to resume.", "muted"); + await waitForRingSpace(control); + logStep("Ring buffer has capacity again; fetch pump resumed.", "muted"); + continue; + } + + const write = Atomics.load(control, streamControlIndex.write); + const space = streamBufferCapacity - available; + const byteCount = Math.min(chunk.byteLength - offset, space, streamBufferCapacity - write); + bytes.set(chunk.subarray(offset, offset + byteCount), write); + Atomics.store(control, streamControlIndex.write, (write + byteCount) % streamBufferCapacity); + Atomics.add(control, streamControlIndex.available, byteCount); + Atomics.add(control, streamControlIndex.totalWritten, byteCount); + offset += byteCount; + wakeRingBuffer(control); + } +} + +async function waitForRingSpace(control) { + while (Atomics.load(control, streamControlIndex.available) >= streamBufferCapacity) { + await new Promise((resolve) => setTimeout(resolve, 4)); + } +} + +function wakeRingBuffer(control) { + Atomics.add(control, streamControlIndex.sequence, 1); + Atomics.notify(control, streamControlIndex.sequence); +} + +async function loadWasmModule(url) { + if (!modulePromise) { + modulePromise = loadWasmBytes(url).then((bytes) => WebAssembly.compile(bytes)); + } + return modulePromise; +} + +async function loadWasmBytes(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch WASM: HTTP ${response.status}`); + } + const bytes = await response.arrayBuffer(); + if (!url.endsWith(".gz")) { + return bytes; + } + if (!("DecompressionStream" in globalThis)) { + throw new Error("This browser does not support DecompressionStream."); + } + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Response(stream).arrayBuffer(); +} + +function parseSmokeOutput(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function renderFailure(error) { + statusElement.className = "status fail"; + statusElement.textContent = "Failed"; + outputElement.textContent = String(error?.stack || error); + logStep(`Failed: ${String(error?.message || error)}`, "error"); + resetButtons(); +} + +function resetButtons() { + runButton.disabled = false; + streamButton.disabled = !streamingSupported(); +} + +function updateStreamingCapabilities() { + const isolated = crossOriginIsolated === true; + const hasSharedArrayBuffer = "SharedArrayBuffer" in globalThis; + const hasWorker = "Worker" in globalThis; + const hasAtomicsWait = typeof Atomics?.wait === "function"; + isolationMetric.textContent = isolated ? "yes" : "no"; + sabMetric.textContent = hasSharedArrayBuffer ? "yes" : "no"; + workerMetric.textContent = hasWorker && hasAtomicsWait ? "yes" : "no"; + modeMetric.textContent = streamingSupported() ? "streaming enabled" : "buffered fallback"; + resetButtons(); +} + +function streamingSupported() { + return crossOriginIsolated === true + && "SharedArrayBuffer" in globalThis + && "Worker" in globalThis + && typeof Atomics?.wait === "function"; +} + +function resetMetrics() { + bytesMetric.textContent = "-"; + fetchMetric.textContent = "-"; + parseMetric.textContent = "-"; + throughputMetric.textContent = "-"; + documentsMetric.textContent = "-"; +} + +async function copyRunLog() { + const text = runLogElement.innerText.trim(); + if (!text) { + return; + } + try { + if ("clipboard" in navigator && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + copyTextWithTextarea(text); + } + } catch { + copyTextWithTextarea(text); + } + const previousLabel = copyRunLogButton.textContent; + copyRunLogButton.textContent = "Copied"; + window.setTimeout(() => { + copyRunLogButton.textContent = previousLabel; + }, 1500); +} + +function copyTextWithTextarea(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +function resetStreamDocument(message) { + documentRenderQueue = []; + documentRenderResolvers.splice(0).forEach((resolve) => resolve()); + streamDocumentElement.replaceChildren(document.createTextNode(message)); + streamDocumentElement.scrollTop = 0; +} + +function replaceStreamDocument(text) { + documentRenderQueue = []; + documentRenderResolvers.splice(0).forEach((resolve) => resolve()); + streamDocumentElement.replaceChildren(); + appendStreamDocumentChunk(text || "(empty document)"); + streamDocumentElement.scrollTop = 0; +} + +function appendStreamDocumentChunk(text) { + if (!text) { + return; + } + if (streamDocumentElement.childNodes.length === 1 && streamDocumentElement.textContent.startsWith("Waiting for")) { + streamDocumentElement.replaceChildren(); + } + documentRenderQueue.push(text); + scheduleDocumentFlush(); +} + +function decodeYAMLBytes(bytes) { + return new TextDecoder().decode(bytes); +} + +function scheduleDocumentFlush() { + if (documentRenderScheduled) { + return; + } + documentRenderScheduled = true; + requestAnimationFrame(flushDocumentRenderQueue); +} + +function flushDocumentRenderQueue() { + documentRenderScheduled = false; + let remainingBudget = documentRenderBudget; + const fragment = document.createDocumentFragment(); + + while (documentRenderQueue.length > 0 && remainingBudget > 0) { + const next = documentRenderQueue.shift(); + const piece = next.length > remainingBudget ? next.slice(0, remainingBudget) : next; + const rest = next.length > remainingBudget ? next.slice(remainingBudget) : ""; + fragment.append(renderYAMLSyntax(piece)); + remainingBudget -= piece.length; + if (rest) { + documentRenderQueue.unshift(rest); + } + } + + streamDocumentElement.append(fragment); + streamDocumentElement.scrollTop = streamDocumentElement.scrollHeight; + + if (documentRenderQueue.length > 0) { + scheduleDocumentFlush(); + return; + } + + documentRenderResolvers.splice(0).forEach((resolve) => resolve()); +} + +function waitForDocumentFlush() { + if (!documentRenderScheduled && documentRenderQueue.length === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + documentRenderResolvers.push(resolve); + }); +} + +function renderYAMLSyntax(text) { + const fragment = document.createDocumentFragment(); + for (const segment of text.split(/(\r?\n)/)) { + if (segment === "\n" || segment === "\r\n") { + fragment.append(document.createTextNode(segment)); + } else { + renderYAMLLine(segment, fragment); + } + } + return fragment; +} + +function renderYAMLLine(line, fragment) { + const commentIndex = findYAMLCommentIndex(line); + const source = commentIndex >= 0 ? line.slice(0, commentIndex) : line; + const comment = commentIndex >= 0 ? line.slice(commentIndex) : ""; + const keyMatch = source.match(/^(\s*(?:-\s*)?)([A-Za-z0-9_.-]+)(\s*:)(.*)$/); + if (source.trim() === "---" || source.trim() === "...") { + appendSyntaxSpan(fragment, source, "yaml-marker"); + } else if (keyMatch) { + fragment.append(document.createTextNode(keyMatch[1])); + appendSyntaxSpan(fragment, keyMatch[2], "yaml-key"); + appendSyntaxSpan(fragment, keyMatch[3], "yaml-punctuation"); + appendYAMLValue(fragment, keyMatch[4]); + } else { + appendYAMLValue(fragment, source); + } + if (comment) { + appendSyntaxSpan(fragment, comment, "yaml-comment"); + } +} + +function appendYAMLValue(fragment, value) { + const trimmed = value.trim(); + const leading = value.slice(0, value.length - value.trimStart().length); + const trailingStart = value.length - value.trimEnd().length; + const trailing = trailingStart > 0 ? value.slice(value.length - trailingStart) : ""; + if (leading) { + fragment.append(document.createTextNode(leading)); + } + if (/^(true|false|null|~)$/i.test(trimmed)) { + appendSyntaxSpan(fragment, trimmed, "yaml-literal"); + } else if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) { + appendSyntaxSpan(fragment, trimmed, "yaml-number"); + } else if (/^(['"]).*\1$/.test(trimmed)) { + appendSyntaxSpan(fragment, trimmed, "yaml-string"); + } else { + fragment.append(document.createTextNode(trimmed)); + } + if (trailing) { + fragment.append(document.createTextNode(trailing)); + } +} + +function appendSyntaxSpan(fragment, text, className) { + const span = document.createElement("span"); + span.className = className; + span.textContent = text; + fragment.append(span); +} + +function findYAMLCommentIndex(line) { + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if ((character === "'" || character === "\"") && line[index - 1] !== "\\") { + quote = quote === character ? null : quote || character; + } + if (character === "#" && quote === null && (index === 0 || /\s/.test(line[index - 1]))) { + return index; + } + } + return -1; +} + +function formatDuration(milliseconds) { + if (milliseconds < 1000) { + return `${milliseconds.toFixed(1)} ms`; + } + return `${(milliseconds / 1000).toFixed(3)} s`; +} + +function formatBytes(byteCount) { + if (byteCount < 1024) { + return `${byteCount.toFixed(0)} B`; + } + if (byteCount < 1024 * 1024) { + return `${(byteCount / 1024).toFixed(1)} KiB`; + } + return `${(byteCount / 1024 / 1024).toFixed(2)} MiB`; +} + +function resetLog() { + runStartedAt = performance.now(); + runLogElement.replaceChildren(); +} + +function logStep(message, level = "info") { + const entry = document.createElement("div"); + entry.className = `log-entry ${level}`; + const elapsed = (performance.now() - runStartedAt) / 1000; + entry.textContent = `[+${elapsed.toFixed(3)}s] ${message}`; + runLogElement.append(entry); + runLogElement.scrollTop = runLogElement.scrollHeight; +} + +function nextPaint() { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(resolve); + }); + }); +} diff --git a/pureyaml-streaming-wasm-smoke.wasm.gz b/pureyaml-streaming-wasm-smoke.wasm.gz new file mode 100644 index 0000000..f0dcdc1 Binary files /dev/null and b/pureyaml-streaming-wasm-smoke.wasm.gz differ diff --git a/stream-worker.js b/stream-worker.js new file mode 100644 index 0000000..d46c62a --- /dev/null +++ b/stream-worker.js @@ -0,0 +1,126 @@ +import { + ConsoleStdout, + File, + OpenFile, + WASI, +} from "./vendor/browser_wasi_shim/index.js"; + +const controlIndex = { + read: 0, + write: 1, + available: 2, + closed: 3, + error: 4, + sequence: 5, + totalWritten: 6, + readCalls: 7, + waitCount: 8, +}; + +self.addEventListener("message", (event) => { + if (event.data?.type !== "start") { + return; + } + + runStreamingBenchmark(event.data).catch((error) => { + self.postMessage({ + type: "error", + message: String(error?.stack || error), + }); + }); +}); + +async function runStreamingBenchmark(config) { + const moduleLoadStart = performance.now(); + const module = await loadWasmModule(config.wasmURL); + const moduleLoadMS = performance.now() - moduleLoadStart; + self.postMessage({ type: "log", message: `Worker loaded WASM module in ${moduleLoadMS.toFixed(1)} ms.` }); + let stdoutText = ""; + let stderrText = ""; + const stdin = new StreamingStdin(config.sharedBuffer, config.capacity); + const wasi = new WASI( + ["pureyaml-streaming-wasm-smoke", "--stream-stdin", config.sourceURL, String(config.statusCode)], + [], + [ + stdin, + ConsoleStdout.lineBuffered((line) => { + stdoutText += `${line}\n`; + self.postMessage({ type: "stdout", line }); + }), + ConsoleStdout.lineBuffered((line) => { + stderrText += `${line}\n`; + self.postMessage({ type: "stderr", line }); + }), + ], + ); + + self.postMessage({ type: "log", message: "Worker instantiated WASI runtime." }); + const processStart = performance.now(); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + self.postMessage({ type: "log", message: "Worker entering WASI _start with streaming stdin." }); + wasi.start(instance); + const processWallClockMS = performance.now() - processStart; + self.postMessage({ + type: "done", + stdoutText, + stderrText, + moduleLoadMS, + processWallClockMS, + readCalls: Atomics.load(stdin.control, controlIndex.readCalls), + waitCount: Atomics.load(stdin.control, controlIndex.waitCount), + }); +} + +async function loadWasmModule(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch WASM in worker: HTTP ${response.status}`); + } + const bytes = await response.arrayBuffer(); + if (!url.endsWith(".gz")) { + return WebAssembly.compile(bytes); + } + if (!("DecompressionStream" in globalThis)) { + throw new Error("This browser does not support DecompressionStream in workers."); + } + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return WebAssembly.compile(await new Response(stream).arrayBuffer()); +} + +class StreamingStdin extends OpenFile { + constructor(sharedBuffer, capacity) { + super(new File([])); + this.control = new Int32Array(sharedBuffer, 0, 16); + this.bytes = new Uint8Array(sharedBuffer, 16 * Int32Array.BYTES_PER_ELEMENT, capacity); + this.capacity = capacity; + } + + fd_read(length) { + const data = this.readBlocking(length); + return { ret: 0, data }; + } + + readBlocking(length) { + while (Atomics.load(this.control, controlIndex.available) === 0) { + if (Atomics.load(this.control, controlIndex.closed) === 1 || Atomics.load(this.control, controlIndex.error) === 1) { + return new Uint8Array(); + } + Atomics.add(this.control, controlIndex.waitCount, 1); + const sequence = Atomics.load(this.control, controlIndex.sequence); + Atomics.wait(this.control, controlIndex.sequence, sequence); + } + + const available = Atomics.load(this.control, controlIndex.available); + const read = Atomics.load(this.control, controlIndex.read); + const byteCount = Math.min(length, available, this.capacity - read); + const result = this.bytes.slice(read, read + byteCount); + Atomics.store(this.control, controlIndex.read, (read + byteCount) % this.capacity); + Atomics.sub(this.control, controlIndex.available, byteCount); + Atomics.add(this.control, controlIndex.readCalls, 1); + Atomics.add(this.control, controlIndex.sequence, 1); + Atomics.notify(this.control, controlIndex.sequence); + return result; + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..3508252 --- /dev/null +++ b/style.css @@ -0,0 +1,243 @@ +:root { + color-scheme: light dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + background: Canvas; + color: CanvasText; +} + +body { + margin: 0; + min-height: 100vh; + padding: 28px 0; +} + +main { + width: min(1040px, calc(100vw - 32px)); + margin: 0 auto; + display: grid; + gap: 20px; +} + +.card { + border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); + border-radius: 20px; + padding: 28px; + background: color-mix(in srgb, Canvas 92%, CanvasText 8%); + box-shadow: 0 24px 80px color-mix(in srgb, CanvasText 12%, transparent); +} + +.eyebrow { + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 12px; + opacity: 0.7; +} + +h1, +h2 { + margin: 0 0 12px; + line-height: 1.05; +} + +h1 { + font-size: clamp(36px, 7vw, 72px); +} + +p { + max-width: 720px; + line-height: 1.6; + opacity: 0.82; +} + +label { + display: block; + margin: 18px 0 8px; + font-weight: 800; +} + +select, +input { + box-sizing: border-box; + width: 100%; + border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); + border-radius: 14px; + padding: 12px 14px; + background: Canvas; + color: CanvasText; + font: inherit; +} + +button { + appearance: none; + border: 0; + border-radius: 999px; + margin-top: 18px; + margin-right: 10px; + padding: 12px 18px; + font: inherit; + font-weight: 700; + cursor: pointer; + background: CanvasText; + color: Canvas; +} + +#streamButton { + border: 1px solid CanvasText; + background: Canvas; + color: CanvasText; +} + +.secondary-button { + margin-top: 0; + border: 1px solid CanvasText; + background: Canvas; + color: CanvasText; +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.section-header h2 { + margin-bottom: 0; +} + +.status { + margin-top: 18px; + display: inline-flex; + border-radius: 999px; + padding: 8px 12px; + font-weight: 700; + background: color-mix(in srgb, CanvasText 10%, transparent); +} + +.status.pass { + background: color-mix(in srgb, #008a45 30%, transparent); +} + +.status.fail { + background: color-mix(in srgb, #c21f39 30%, transparent); +} + +.metrics { + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 14px; +} + +.metrics div { + border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); + border-radius: 16px; + padding: 16px; + background: color-mix(in srgb, Canvas 96%, CanvasText 4%); +} + +dt { + margin: 0 0 8px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.65; +} + +dd { + margin: 0; + font: 700 24px/1.1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +#isolationMetric, +#sabMetric, +#workerMetric, +#modeMetric { + font-size: 16px; +} + +.run-log { + height: min(320px, 45vh); + overflow-y: auto; + border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); + border-radius: 16px; + padding: 14px; + background: color-mix(in srgb, Canvas 96%, CanvasText 4%); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.stream-document { + height: min(420px, 55vh); + overflow: auto; + border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); + border-radius: 16px; + padding: 14px; + background: color-mix(in srgb, Canvas 96%, CanvasText 4%); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.yaml-key { + color: #4f8cff; + font-weight: 700; +} + +.yaml-punctuation, +.yaml-marker { + color: #b66cff; + font-weight: 700; +} + +.yaml-comment { + color: #708090; + font-style: italic; +} + +.yaml-string { + color: #138a36; +} + +.yaml-number { + color: #b36b00; +} + +.yaml-literal { + color: #c21f39; + font-weight: 700; +} + +.log-entry { + padding: 6px 0; + border-bottom: 1px solid color-mix(in srgb, CanvasText 8%, transparent); + overflow-wrap: anywhere; +} + +.log-entry:last-child { + border-bottom: 0; +} + +.log-entry.muted { + opacity: 0.65; +} + +.log-entry.error { + color: #c21f39; + font-weight: 700; +} + +pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.5; +} diff --git a/vendor/browser_wasi_shim/debug.js b/vendor/browser_wasi_shim/debug.js new file mode 100644 index 0000000..b7e03ac --- /dev/null +++ b/vendor/browser_wasi_shim/debug.js @@ -0,0 +1 @@ +let Debug=class Debug{enable(enabled){this.log=createLogger(enabled===undefined?true:enabled,this.prefix)}get enabled(){return this.isEnabled}constructor(isEnabled){this.isEnabled=isEnabled;this.prefix="wasi:";this.enable(isEnabled)}};function createLogger(enabled,prefix){if(enabled){const a=console.log.bind(console,"%c%s","color: #265BA0",prefix);return a}else{return()=>{}}}export const debug=new Debug(false); \ No newline at end of file diff --git a/vendor/browser_wasi_shim/fd.js b/vendor/browser_wasi_shim/fd.js new file mode 100644 index 0000000..7e0526b --- /dev/null +++ b/vendor/browser_wasi_shim/fd.js @@ -0,0 +1 @@ +import*as wasi from"./wasi_defs.js";export class Fd{fd_allocate(offset,len){return wasi.ERRNO_NOTSUP}fd_close(){return 0}fd_fdstat_get(){return{ret:wasi.ERRNO_NOTSUP,fdstat:null}}fd_fdstat_set_flags(flags){return wasi.ERRNO_NOTSUP}fd_fdstat_set_rights(fs_rights_base,fs_rights_inheriting){return wasi.ERRNO_NOTSUP}fd_filestat_get(){return{ret:wasi.ERRNO_NOTSUP,filestat:null}}fd_filestat_set_size(size){return wasi.ERRNO_NOTSUP}fd_filestat_set_times(atim,mtim,fst_flags){return wasi.ERRNO_NOTSUP}fd_pread(size,offset){return{ret:wasi.ERRNO_NOTSUP,data:new Uint8Array}}fd_prestat_get(){return{ret:wasi.ERRNO_NOTSUP,prestat:null}}fd_pwrite(data,offset){return{ret:wasi.ERRNO_NOTSUP,nwritten:0}}fd_read(size){return{ret:wasi.ERRNO_NOTSUP,data:new Uint8Array}}fd_readdir_single(cookie){return{ret:wasi.ERRNO_NOTSUP,dirent:null}}fd_seek(offset,whence){return{ret:wasi.ERRNO_NOTSUP,offset:0n}}fd_sync(){return 0}fd_tell(){return{ret:wasi.ERRNO_NOTSUP,offset:0n}}fd_write(data){return{ret:wasi.ERRNO_NOTSUP,nwritten:0}}path_create_directory(path){return wasi.ERRNO_NOTSUP}path_filestat_get(flags,path){return{ret:wasi.ERRNO_NOTSUP,filestat:null}}path_filestat_set_times(flags,path,atim,mtim,fst_flags){return wasi.ERRNO_NOTSUP}path_link(path,inode,allow_dir){return wasi.ERRNO_NOTSUP}path_unlink(path){return{ret:wasi.ERRNO_NOTSUP,inode_obj:null}}path_lookup(path,dirflags){return{ret:wasi.ERRNO_NOTSUP,inode_obj:null}}path_open(dirflags,path,oflags,fs_rights_base,fs_rights_inheriting,fd_flags){return{ret:wasi.ERRNO_NOTDIR,fd_obj:null}}path_readlink(path){return{ret:wasi.ERRNO_NOTSUP,data:null}}path_remove_directory(path){return wasi.ERRNO_NOTSUP}path_rename(old_path,new_fd,new_path){return wasi.ERRNO_NOTSUP}path_unlink_file(path){return wasi.ERRNO_NOTSUP}}export class Inode{} \ No newline at end of file diff --git a/vendor/browser_wasi_shim/fs_mem.js b/vendor/browser_wasi_shim/fs_mem.js new file mode 100644 index 0000000..21393d0 --- /dev/null +++ b/vendor/browser_wasi_shim/fs_mem.js @@ -0,0 +1 @@ +import{debug}from"./debug.js";import*as wasi from"./wasi_defs.js";import{Fd,Inode}from"./fd.js";export class OpenFile extends Fd{fd_allocate(offset,len){if(this.file.size>offset+len){}else{const new_data=new Uint8Array(Number(offset+len));new_data.set(this.file.data,0);this.file.data=new_data}return wasi.ERRNO_SUCCESS}fd_fdstat_get(){return{ret:0,fdstat:new wasi.Fdstat(wasi.FILETYPE_REGULAR_FILE,0)}}fd_filestat_set_size(size){if(this.file.size>size){this.file.data=new Uint8Array(this.file.data.buffer.slice(0,Number(size)))}else{const new_data=new Uint8Array(Number(size));new_data.set(this.file.data,0);this.file.data=new_data}return wasi.ERRNO_SUCCESS}fd_read(size){const slice=this.file.data.slice(Number(this.file_pos),Number(this.file_pos+BigInt(size)));this.file_pos+=BigInt(slice.length);return{ret:0,data:slice}}fd_pread(size,offset){const slice=this.file.data.slice(Number(offset),Number(offset+BigInt(size)));return{ret:0,data:slice}}fd_seek(offset,whence){let calculated_offset;switch(whence){case wasi.WHENCE_SET:calculated_offset=offset;break;case wasi.WHENCE_CUR:calculated_offset=this.file_pos+offset;break;case wasi.WHENCE_END:calculated_offset=BigInt(this.file.data.byteLength)+offset;break;default:return{ret:wasi.ERRNO_INVAL,offset:0n}}if(calculated_offset<0){return{ret:wasi.ERRNO_INVAL,offset:0n}}this.file_pos=calculated_offset;return{ret:0,offset:this.file_pos}}fd_tell(){return{ret:0,offset:this.file_pos}}fd_write(data){if(this.file.readonly)return{ret:wasi.ERRNO_BADF,nwritten:0};if(this.file_pos+BigInt(data.byteLength)>this.file.size){const old=this.file.data;this.file.data=new Uint8Array(Number(this.file_pos+BigInt(data.byteLength)));this.file.data.set(old)}this.file.data.set(data,Number(this.file_pos));this.file_pos+=BigInt(data.byteLength);return{ret:0,nwritten:data.byteLength}}fd_pwrite(data,offset){if(this.file.readonly)return{ret:wasi.ERRNO_BADF,nwritten:0};if(offset+BigInt(data.byteLength)>this.file.size){const old=this.file.data;this.file.data=new Uint8Array(Number(offset+BigInt(data.byteLength)));this.file.data.set(old)}this.file.data.set(data,Number(offset));return{ret:0,nwritten:data.byteLength}}fd_filestat_get(){return{ret:0,filestat:this.file.stat()}}constructor(file){super();this.file_pos=0n;this.file=file}}export class OpenDirectory extends Fd{fd_seek(offset,whence){return{ret:wasi.ERRNO_BADF,offset:0n}}fd_tell(){return{ret:wasi.ERRNO_BADF,offset:0n}}fd_allocate(offset,len){return wasi.ERRNO_BADF}fd_fdstat_get(){return{ret:0,fdstat:new wasi.Fdstat(wasi.FILETYPE_DIRECTORY,0)}}fd_readdir_single(cookie){if(debug.enabled){debug.log("readdir_single",cookie);debug.log(cookie,this.dir.contents.keys())}if(cookie==0n){return{ret:wasi.ERRNO_SUCCESS,dirent:new wasi.Dirent(1n,".",wasi.FILETYPE_DIRECTORY)}}else if(cookie==1n){return{ret:wasi.ERRNO_SUCCESS,dirent:new wasi.Dirent(2n,"..",wasi.FILETYPE_DIRECTORY)}}if(cookie>=BigInt(this.dir.contents.size)+2n){return{ret:0,dirent:null}}const[name,entry]=Array.from(this.dir.contents.entries())[Number(cookie-2n)];return{ret:0,dirent:new wasi.Dirent(cookie+1n,name,entry.stat().filetype)}}path_filestat_get(flags,path_str){const{ret:path_err,path}=Path.from(path_str);if(path==null){return{ret:path_err,filestat:null}}const{ret,entry}=this.dir.get_entry_for_path(path);if(entry==null){return{ret,filestat:null}}return{ret:0,filestat:entry.stat()}}path_lookup(path_str,dirflags){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return{ret:path_ret,inode_obj:null}}const{ret,entry}=this.dir.get_entry_for_path(path);if(entry==null){return{ret,inode_obj:null}}return{ret:wasi.ERRNO_SUCCESS,inode_obj:entry}}path_open(dirflags,path_str,oflags,fs_rights_base,fs_rights_inheriting,fd_flags){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return{ret:path_ret,fd_obj:null}}let{ret,entry}=this.dir.get_entry_for_path(path);if(entry==null){if(ret!=wasi.ERRNO_NOENT){return{ret,fd_obj:null}}if((oflags&wasi.OFLAGS_CREAT)==wasi.OFLAGS_CREAT){const{ret,entry:new_entry}=this.dir.create_entry_for_path(path_str,(oflags&wasi.OFLAGS_DIRECTORY)==wasi.OFLAGS_DIRECTORY);if(new_entry==null){return{ret,fd_obj:null}}entry=new_entry}else{return{ret:wasi.ERRNO_NOENT,fd_obj:null}}}else if((oflags&wasi.OFLAGS_EXCL)==wasi.OFLAGS_EXCL){return{ret:wasi.ERRNO_EXIST,fd_obj:null}}if((oflags&wasi.OFLAGS_DIRECTORY)==wasi.OFLAGS_DIRECTORY&&entry.stat().filetype!==wasi.FILETYPE_DIRECTORY){return{ret:wasi.ERRNO_NOTDIR,fd_obj:null}}return entry.path_open(oflags,fs_rights_base,fd_flags)}path_create_directory(path){return this.path_open(0,path,wasi.OFLAGS_CREAT|wasi.OFLAGS_DIRECTORY,0n,0n,0).ret}path_link(path_str,inode,allow_dir){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return path_ret}if(path.is_dir){return wasi.ERRNO_NOENT}const{ret:parent_ret,parent_entry,filename,entry}=this.dir.get_parent_dir_and_entry_for_path(path,true);if(parent_entry==null||filename==null){return parent_ret}if(entry!=null){const source_is_dir=inode.stat().filetype==wasi.FILETYPE_DIRECTORY;const target_is_dir=entry.stat().filetype==wasi.FILETYPE_DIRECTORY;if(source_is_dir&&target_is_dir){if(allow_dir&&entry instanceof Directory){if(entry.contents.size==0){}else{return wasi.ERRNO_NOTEMPTY}}else{return wasi.ERRNO_EXIST}}else if(source_is_dir&&!target_is_dir){return wasi.ERRNO_NOTDIR}else if(!source_is_dir&&target_is_dir){return wasi.ERRNO_ISDIR}else if(inode.stat().filetype==wasi.FILETYPE_REGULAR_FILE&&entry.stat().filetype==wasi.FILETYPE_REGULAR_FILE){}else{return wasi.ERRNO_EXIST}}if(!allow_dir&&inode.stat().filetype==wasi.FILETYPE_DIRECTORY){return wasi.ERRNO_PERM}parent_entry.contents.set(filename,inode);return wasi.ERRNO_SUCCESS}path_unlink(path_str){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return{ret:path_ret,inode_obj:null}}const{ret:parent_ret,parent_entry,filename,entry}=this.dir.get_parent_dir_and_entry_for_path(path,true);if(parent_entry==null||filename==null){return{ret:parent_ret,inode_obj:null}}if(entry==null){return{ret:wasi.ERRNO_NOENT,inode_obj:null}}parent_entry.contents.delete(filename);return{ret:wasi.ERRNO_SUCCESS,inode_obj:entry}}path_unlink_file(path_str){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return path_ret}const{ret:parent_ret,parent_entry,filename,entry}=this.dir.get_parent_dir_and_entry_for_path(path,false);if(parent_entry==null||filename==null||entry==null){return parent_ret}if(entry.stat().filetype===wasi.FILETYPE_DIRECTORY){return wasi.ERRNO_ISDIR}parent_entry.contents.delete(filename);return wasi.ERRNO_SUCCESS}path_remove_directory(path_str){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return path_ret}const{ret:parent_ret,parent_entry,filename,entry}=this.dir.get_parent_dir_and_entry_for_path(path,false);if(parent_entry==null||filename==null||entry==null){return parent_ret}if(!(entry instanceof Directory)||entry.stat().filetype!==wasi.FILETYPE_DIRECTORY){return wasi.ERRNO_NOTDIR}if(entry.contents.size!==0){return wasi.ERRNO_NOTEMPTY}if(!parent_entry.contents.delete(filename)){return wasi.ERRNO_NOENT}return wasi.ERRNO_SUCCESS}fd_filestat_get(){return{ret:0,filestat:this.dir.stat()}}fd_filestat_set_size(size){return wasi.ERRNO_BADF}fd_read(size){return{ret:wasi.ERRNO_BADF,data:new Uint8Array}}fd_pread(size,offset){return{ret:wasi.ERRNO_BADF,data:new Uint8Array}}fd_write(data){return{ret:wasi.ERRNO_BADF,nwritten:0}}fd_pwrite(data,offset){return{ret:wasi.ERRNO_BADF,nwritten:0}}constructor(dir){super();this.dir=dir}}export class PreopenDirectory extends OpenDirectory{fd_prestat_get(){return{ret:0,prestat:wasi.Prestat.dir(this.prestat_name)}}constructor(name,contents){super(new Directory(contents));this.prestat_name=name}}export class File extends Inode{path_open(oflags,fs_rights_base,fd_flags){if(this.readonly&&(fs_rights_base&BigInt(wasi.RIGHTS_FD_WRITE))==BigInt(wasi.RIGHTS_FD_WRITE)){return{ret:wasi.ERRNO_PERM,fd_obj:null}}if((oflags&wasi.OFLAGS_TRUNC)==wasi.OFLAGS_TRUNC){if(this.readonly)return{ret:wasi.ERRNO_PERM,fd_obj:null};this.data=new Uint8Array([])}const file=new OpenFile(this);if(fd_flags&wasi.FDFLAGS_APPEND)file.fd_seek(0n,wasi.WHENCE_END);return{ret:wasi.ERRNO_SUCCESS,fd_obj:file}}get size(){return BigInt(this.data.byteLength)}stat(){return new wasi.Filestat(wasi.FILETYPE_REGULAR_FILE,this.size)}constructor(data,options){super();this.data=new Uint8Array(data);this.readonly=!!options?.readonly}}let Path=class Path{static from(path){const self=new Path;self.is_dir=path.endsWith("/");if(path.startsWith("/")){return{ret:wasi.ERRNO_NOTCAPABLE,path:null}}if(path.includes("\x00")){return{ret:wasi.ERRNO_INVAL,path:null}}for(const component of path.split("/")){if(component===""||component==="."){continue}if(component===".."){if(self.parts.pop()==undefined){return{ret:wasi.ERRNO_NOTCAPABLE,path:null}}continue}self.parts.push(component)}return{ret:wasi.ERRNO_SUCCESS,path:self}}to_path_string(){let s=this.parts.join("/");if(this.is_dir){s+="/"}return s}constructor(){this.parts=[];this.is_dir=false}};export class Directory extends Inode{path_open(oflags,fs_rights_base,fd_flags){return{ret:wasi.ERRNO_SUCCESS,fd_obj:new OpenDirectory(this)}}stat(){return new wasi.Filestat(wasi.FILETYPE_DIRECTORY,0n)}get_entry_for_path(path){let entry=this;for(const component of path.parts){if(!(entry instanceof Directory)){return{ret:wasi.ERRNO_NOTDIR,entry:null}}const child=entry.contents.get(component);if(child!==undefined){entry=child}else{debug.log(component);return{ret:wasi.ERRNO_NOENT,entry:null}}}if(path.is_dir){if(entry.stat().filetype!=wasi.FILETYPE_DIRECTORY){return{ret:wasi.ERRNO_NOTDIR,entry:null}}}return{ret:wasi.ERRNO_SUCCESS,entry}}get_parent_dir_and_entry_for_path(path,allow_undefined){const filename=path.parts.pop();if(filename===undefined){return{ret:wasi.ERRNO_INVAL,parent_entry:null,filename:null,entry:null}}const{ret:entry_ret,entry:parent_entry}=this.get_entry_for_path(path);if(parent_entry==null){return{ret:entry_ret,parent_entry:null,filename:null,entry:null}}if(!(parent_entry instanceof Directory)){return{ret:wasi.ERRNO_NOTDIR,parent_entry:null,filename:null,entry:null}}const entry=parent_entry.contents.get(filename);if(entry===undefined){if(!allow_undefined){return{ret:wasi.ERRNO_NOENT,parent_entry:null,filename:null,entry:null}}else{return{ret:wasi.ERRNO_SUCCESS,parent_entry,filename,entry:null}}}if(path.is_dir){if(entry.stat().filetype!=wasi.FILETYPE_DIRECTORY){return{ret:wasi.ERRNO_NOTDIR,parent_entry:null,filename:null,entry:null}}}return{ret:wasi.ERRNO_SUCCESS,parent_entry,filename,entry}}create_entry_for_path(path_str,is_dir){const{ret:path_ret,path}=Path.from(path_str);if(path==null){return{ret:path_ret,entry:null}}let{ret:parent_ret,parent_entry,filename,entry}=this.get_parent_dir_and_entry_for_path(path,true);if(parent_entry==null||filename==null){return{ret:parent_ret,entry:null}}if(entry!=null){return{ret:wasi.ERRNO_EXIST,entry:null}}debug.log("create",path);let new_child;if(!is_dir){new_child=new File(new ArrayBuffer(0))}else{new_child=new Directory(new Map)}parent_entry.contents.set(filename,new_child);entry=new_child;return{ret:wasi.ERRNO_SUCCESS,entry}}constructor(contents){super();if(contents instanceof Array){this.contents=new Map(contents)}else{this.contents=contents}}}export class ConsoleStdout extends Fd{fd_filestat_get(){const filestat=new wasi.Filestat(wasi.FILETYPE_CHARACTER_DEVICE,BigInt(0));return{ret:0,filestat}}fd_fdstat_get(){const fdstat=new wasi.Fdstat(wasi.FILETYPE_CHARACTER_DEVICE,0);fdstat.fs_rights_base=BigInt(wasi.RIGHTS_FD_WRITE);return{ret:0,fdstat}}fd_write(data){this.write(data);return{ret:0,nwritten:data.byteLength}}static lineBuffered(write){const dec=new TextDecoder("utf-8",{fatal:false});let line_buf="";return new ConsoleStdout(buffer=>{line_buf+=dec.decode(buffer,{stream:true});const lines=line_buf.split("\n");for(const[i,line]of lines.entries()){if(ioffset+len){}else{this.file.handle.truncate(Number(offset+len))}return wasi.ERRNO_SUCCESS}fd_fdstat_get(){return{ret:0,fdstat:new wasi.Fdstat(wasi.FILETYPE_REGULAR_FILE,0)}}fd_filestat_get(){return{ret:0,filestat:new wasi.Filestat(wasi.FILETYPE_REGULAR_FILE,BigInt(this.file.handle.getSize()))}}fd_filestat_set_size(size){this.file.handle.truncate(Number(size));return wasi.ERRNO_SUCCESS}fd_read(size){const buf=new Uint8Array(size);const n=this.file.handle.read(buf,{at:Number(this.position)});this.position+=BigInt(n);return{ret:0,data:buf.slice(0,n)}}fd_seek(offset,whence){let calculated_offset;switch(whence){case wasi.WHENCE_SET:calculated_offset=BigInt(offset);break;case wasi.WHENCE_CUR:calculated_offset=this.position+BigInt(offset);break;case wasi.WHENCE_END:calculated_offset=BigInt(this.file.handle.getSize())+BigInt(offset);break;default:return{ret:wasi.ERRNO_INVAL,offset:0n}}if(calculated_offset<0){return{ret:wasi.ERRNO_INVAL,offset:0n}}this.position=calculated_offset;return{ret:wasi.ERRNO_SUCCESS,offset:this.position}}fd_write(data){if(this.file.readonly)return{ret:wasi.ERRNO_BADF,nwritten:0};const n=this.file.handle.write(data,{at:Number(this.position)});this.position+=BigInt(n);return{ret:wasi.ERRNO_SUCCESS,nwritten:n}}fd_sync(){this.file.handle.flush();return wasi.ERRNO_SUCCESS}constructor(file){super();this.position=0n;this.file=file}} \ No newline at end of file diff --git a/vendor/browser_wasi_shim/index.js b/vendor/browser_wasi_shim/index.js new file mode 100644 index 0000000..ee61855 --- /dev/null +++ b/vendor/browser_wasi_shim/index.js @@ -0,0 +1 @@ +import WASI,{WASIProcExit}from"./wasi.js";export{WASI,WASIProcExit};export{Fd,Inode}from"./fd.js";export{File,Directory,OpenFile,OpenDirectory,PreopenDirectory,ConsoleStdout}from"./fs_mem.js";export{SyncOPFSFile,OpenSyncOPFSFile}from"./fs_opfs.js";export{strace}from"./strace.js";export*as wasi from"./wasi_defs.js"; \ No newline at end of file diff --git a/vendor/browser_wasi_shim/strace.js b/vendor/browser_wasi_shim/strace.js new file mode 100644 index 0000000..a757356 --- /dev/null +++ b/vendor/browser_wasi_shim/strace.js @@ -0,0 +1 @@ +export function strace(imports,no_trace){return new Proxy(imports,{get(target,prop,receiver){const f=Reflect.get(target,prop,receiver);if(no_trace.includes(prop)){return f}return function(...args){console.log(prop,"(",...args,")");const result=Reflect.apply(f,receiver,args);console.log(" =",result);return result}}})} \ No newline at end of file diff --git a/vendor/browser_wasi_shim/wasi.js b/vendor/browser_wasi_shim/wasi.js new file mode 100644 index 0000000..c1a49d4 --- /dev/null +++ b/vendor/browser_wasi_shim/wasi.js @@ -0,0 +1 @@ +import*as wasi from"./wasi_defs.js";import{debug}from"./debug.js";export class WASIProcExit extends Error{constructor(code){super("exit with exit code "+code);this.code=code}}let WASI=class WASI{start(instance){this.inst=instance;try{instance.exports._start();return 0}catch(e){if(e instanceof WASIProcExit){return e.code}else{throw e}}}initialize(instance){this.inst=instance;if(instance.exports._initialize){instance.exports._initialize()}}constructor(args,env,fds,options={}){this.args=[];this.env=[];this.fds=[];debug.enable(options.debug);this.args=args;this.env=env;this.fds=fds;const self=this;this.wasiImport={args_sizes_get(argc,argv_buf_size){const buffer=new DataView(self.inst.exports.memory.buffer);buffer.setUint32(argc,self.args.length,true);let buf_size=0;for(const arg of self.args){buf_size+=arg.length+1}buffer.setUint32(argv_buf_size,buf_size,true);debug.log(buffer.getUint32(argc,true),buffer.getUint32(argv_buf_size,true));return 0},args_get(argv,argv_buf){const buffer=new DataView(self.inst.exports.memory.buffer);const buffer8=new Uint8Array(self.inst.exports.memory.buffer);const orig_argv_buf=argv_buf;for(let i=0;ipath_len?wasi.ERRNO_NAMETOOLONG:wasi.ERRNO_SUCCESS}else{return wasi.ERRNO_BADF}},fd_pwrite(fd,iovs_ptr,iovs_len,offset,nwritten_ptr){const buffer=new DataView(self.inst.exports.memory.buffer);const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined){const iovecs=wasi.Ciovec.read_bytes_array(buffer,iovs_ptr,iovs_len);let nwritten=0;for(const iovec of iovecs){const data=buffer8.slice(iovec.buf,iovec.buf+iovec.buf_len);const{ret,nwritten:nwritten_part}=self.fds[fd].fd_pwrite(data,offset);if(ret!=wasi.ERRNO_SUCCESS){buffer.setUint32(nwritten_ptr,nwritten,true);return ret}nwritten+=nwritten_part;offset+=BigInt(nwritten_part);if(nwritten_part!=data.byteLength){break}}buffer.setUint32(nwritten_ptr,nwritten,true);return wasi.ERRNO_SUCCESS}else{return wasi.ERRNO_BADF}},fd_read(fd,iovs_ptr,iovs_len,nread_ptr){const buffer=new DataView(self.inst.exports.memory.buffer);const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined){const iovecs=wasi.Iovec.read_bytes_array(buffer,iovs_ptr,iovs_len);let nread=0;for(const iovec of iovecs){const{ret,data}=self.fds[fd].fd_read(iovec.buf_len);if(ret!=wasi.ERRNO_SUCCESS){buffer.setUint32(nread_ptr,nread,true);return ret}buffer8.set(data,iovec.buf);nread+=data.length;if(data.length!=iovec.buf_len){break}}buffer.setUint32(nread_ptr,nread,true);return wasi.ERRNO_SUCCESS}else{return wasi.ERRNO_BADF}},fd_readdir(fd,buf,buf_len,cookie,bufused_ptr){const buffer=new DataView(self.inst.exports.memory.buffer);const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined){let bufused=0;while(true){const{ret,dirent}=self.fds[fd].fd_readdir_single(cookie);if(ret!=0){buffer.setUint32(bufused_ptr,bufused,true);return ret}if(dirent==null){break}if(buf_len-bufusedbuf_len){buffer.setUint32(nread_ptr,0,true);return wasi.ERRNO_BADF}buffer8.set(data_buf,buf_ptr);buffer.setUint32(nread_ptr,data_buf.length,true)}return ret}else{return wasi.ERRNO_BADF}},path_remove_directory(fd,path_ptr,path_len){const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined){const path=new TextDecoder("utf-8").decode(buffer8.slice(path_ptr,path_ptr+path_len));return self.fds[fd].path_remove_directory(path)}else{return wasi.ERRNO_BADF}},path_rename(fd,old_path_ptr,old_path_len,new_fd,new_path_ptr,new_path_len){const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined&&self.fds[new_fd]!=undefined){const old_path=new TextDecoder("utf-8").decode(buffer8.slice(old_path_ptr,old_path_ptr+old_path_len));const new_path=new TextDecoder("utf-8").decode(buffer8.slice(new_path_ptr,new_path_ptr+new_path_len));let{ret,inode_obj}=self.fds[fd].path_unlink(old_path);if(inode_obj==null){return ret}ret=self.fds[new_fd].path_link(new_path,inode_obj,true);if(ret!=wasi.ERRNO_SUCCESS){if(self.fds[fd].path_link(old_path,inode_obj,true)!=wasi.ERRNO_SUCCESS){throw"path_link should always return success when relinking an inode back to the original place"}}return ret}else{return wasi.ERRNO_BADF}},path_symlink(old_path_ptr,old_path_len,fd,new_path_ptr,new_path_len){const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined){const old_path=new TextDecoder("utf-8").decode(buffer8.slice(old_path_ptr,old_path_ptr+old_path_len));const new_path=new TextDecoder("utf-8").decode(buffer8.slice(new_path_ptr,new_path_ptr+new_path_len));return wasi.ERRNO_NOTSUP}else{return wasi.ERRNO_BADF}},path_unlink_file(fd,path_ptr,path_len){const buffer8=new Uint8Array(self.inst.exports.memory.buffer);if(self.fds[fd]!=undefined){const path=new TextDecoder("utf-8").decode(buffer8.slice(path_ptr,path_ptr+path_len));return self.fds[fd].path_unlink_file(path)}else{return wasi.ERRNO_BADF}},poll_oneoff(in_,out,nsubscriptions){throw"async io not supported"},proc_exit(exit_code){throw new WASIProcExit(exit_code)},proc_raise(sig){throw"raised signal "+sig},sched_yield(){},random_get(buf,buf_len){const buffer8=new Uint8Array(self.inst.exports.memory.buffer);for(let i=0;i