-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
Use wasm as a fallback for @tailwindcss/oxide
#20383
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| # `@tailwindcss/oxide-wasm32-wasi` | ||
|
|
||
| This is the **wasm32-wasip1-threads** binary for `@tailwindcss/oxide` | ||
| This is the **wasm32-wasip1-threads** build of `@tailwindcss/oxide` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { css, js, json, test } from '../utils' | ||
| import { css, js, json, test, yaml } from '../utils' | ||
|
|
||
| // This test runs the wasm build using the `node:wasi` runtime. | ||
| // | ||
|
|
@@ -57,3 +57,187 @@ testFn( | |
| `) | ||
| }, | ||
| ) | ||
|
|
||
| testFn( | ||
| '`@tailwindcss/oxide` falls back to the wasm build when no native binding is available', | ||
| { | ||
| fs: { | ||
| 'package.json': json` | ||
| { | ||
| "dependencies": { | ||
| "@tailwindcss/oxide": "workspace:^" | ||
| } | ||
| } | ||
| `, | ||
| 'pnpm-workspace.yaml': yaml` | ||
| # Trick pnpm in only supporting an architecture that @tailwindcss/oxide | ||
| # doesn't support, and therefore should fallback to the wasm version. | ||
| supportedArchitectures: | ||
| os: | ||
| - openbsd | ||
| cpu: | ||
| - x64 | ||
| `, | ||
| 'src/index.js': js` | ||
| const className = "content-['src/index.js']" | ||
| module.exports = { className } | ||
| `, | ||
| 'index.mjs': js` | ||
| import { createRequire } from 'node:module' | ||
| import { join } from 'node:path' | ||
|
|
||
| let require = createRequire(import.meta.url) | ||
| let { Scanner } = require('@tailwindcss/oxide') | ||
|
|
||
| let loaded = Object.keys(require.cache) | ||
|
|
||
| let scanner = new Scanner({ | ||
| sources: [ | ||
| { | ||
| base: join(process.cwd(), 'src'), | ||
| pattern: '**/*', | ||
| negated: false, | ||
| }, | ||
| ], | ||
| }) | ||
|
|
||
| console.log( | ||
| JSON.stringify({ | ||
| native: loaded.filter((file) => file.endsWith('.node')), | ||
| wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')), | ||
| candidates: scanner.scan(), | ||
| }), | ||
| ) | ||
| process.exit() | ||
| `, | ||
| }, | ||
| }, | ||
| async ({ expect, exec }) => { | ||
| // Since vitest runs under `pnpm run`, pnpm's bin shims export a NODE_PATH | ||
| // that includes the repository's hidden hoist directory | ||
| // (`node_modules/.pnpm/node_modules`), which links every workspace package, | ||
| // including all native `@tailwindcss/oxide-*` bindings. | ||
| // | ||
| // Node uses `NODE_PATH` exactly when the local `node_modules` lookup fails, | ||
| // which would defeat the simulated unsupported platform, so clear it. | ||
| let output = await exec(`node index.mjs`, { env: { NODE_PATH: '' } }) | ||
| let { native, wasi, candidates } = JSON.parse(output) | ||
|
|
||
| // No native binding was installed or loaded, ... | ||
| expect(native).toEqual([]) | ||
|
|
||
| // ... the wasm32-wasi binding is what actually loaded, ... | ||
| expect(wasi).toBe(true) | ||
|
|
||
| // ... and scanning real files on disk works through it. | ||
| expect(candidates).toMatchInlineSnapshot(` | ||
| [ | ||
| "className", | ||
| "const", | ||
| "content-['src/index.js']", | ||
| "exports", | ||
| ] | ||
| `) | ||
| }, | ||
| ) | ||
|
|
||
| testFn( | ||
| 'the wasm build loads even when preopening the filesystem root is denied', | ||
| { | ||
| fs: { | ||
| 'package.json': json` | ||
| { | ||
| "dependencies": { | ||
| "@tailwindcss/oxide": "workspace:^" | ||
| } | ||
| } | ||
| `, | ||
| 'pnpm-workspace.yaml': yaml` | ||
| # Trick pnpm in only supporting an architecture that @tailwindcss/oxide | ||
| # doesn't support, and therefore should fallback to the wasm version. | ||
| supportedArchitectures: | ||
| os: | ||
| - openbsd | ||
| cpu: | ||
| - x64 | ||
| `, | ||
| // The wasm bindings generated by `@napi-rs/cli` preopen the filesystem | ||
| // root, which sandboxed platforms (e.g. OpenHarmony, Android) deny with | ||
| // `UVWASI_EACCES`, making the wasm fallback fail to load on exactly the | ||
| // platforms that need it. | ||
| // | ||
| // We patch `@napi-rs/cli`'s templates to retry with narrower preopens | ||
| // (see `patches/@napi-rs__cli@3.7.4.patch`). Simulate such a sandbox by | ||
| // denying `/` preopens. | ||
| 'preload.cjs': js` | ||
| const wasi = require('node:wasi') | ||
| const RealWASI = wasi.WASI | ||
|
|
||
| wasi.WASI = class WASI extends RealWASI { | ||
| constructor(options) { | ||
| if (options?.preopens?.['/'] !== undefined) { | ||
| const error = new Error('UVWASI_EACCES, uvwasi_init') | ||
| error.code = 'UVWASI_EACCES' | ||
| error.syscall = 'uvwasi_init' | ||
| throw error | ||
| } | ||
| super(options) | ||
| } | ||
| } | ||
| `, | ||
|
Comment on lines
+172
to
+187
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Determine whether the generated wasi loader constructs WASI on the main thread, in a worker, or both.
set -uo pipefail
# Locate the generated wasi loader and worker bootstrap emitted by `@napi-rs/cli`.
fd -H -t f -E node_modules 'tailwindcss-oxide\.wasi.*\.(c|m)?js|wasi-worker.*\.(c|m)?js' .
# Show every WASI construction site and its threading context.
fd -H -t f -E node_modules 'wasi' . --exec rg -n -C 6 'new (WASI|__nodeWASI)\(|Worker\(|worker_threads|childThread' {} \;
# Confirm the patch hunks map onto those files.
rg -n 'MessageHandler|childThread|onLoad' patches/@napi-rs__cli@3.7.4.patchRepository: tailwindlabs/tailwindcss Length of output: 275 🏁 Script executed: #!/bin/bash
set -uo pipefail
echo "== test outline =="
ast-grep outline integrations/oxide/wasm.test.ts 2>/dev/null || wc -l integrations/oxide/wasm.test.ts
echo
echo "== relevant wasm.test.ts lines =="
sed -n '130,220p' integrations/oxide/wasm.test.ts
echo
echo "== patch context =="
sed -n '20,90p' patches/@napi-rs__cli@3.7.4.patchRepository: tailwindlabs/tailwindcss Length of output: 4732 🏁 Script executed: #!/bin/bash
set -uo pipefail
echo "== preload shim and required flags =="
sed -n '160,195p' integrations/oxide/wasm.test.ts
echo
echo "== command containing preload.cjs =="
rg -n '\-\-require|preload\.cjs|node --' integrations/oxide/wasm.test.ts
echo
echo "== `@napi-rs` template WASI construction sites and worker context =="
sed -n '1,140p' patches/@napi-rs__cli@3.7.4.patchRepository: tailwindlabs/tailwindcss Length of output: 4518 🌐 Web query:
💡 Result: NAPI-RS supports WebAssembly and WASI targets natively through the Citations:
🌐 Web query:
💡 Result: In Citations:
🏁 Script executed: #!/bin/bash
set -uo pipefail
echo "== wasm runtime WASI construction and worker bootstrap =="
python3 - <<'PY'
import urllib.request
url = "https://unpkg.com/@napi-rs/wasm-runtime@1.2.2/runtime.cjs"
data = urllib.request.urlopen(url, timeout=20).read().decode("utf-8", "replace")
for needle in ["new WASI", "new __nodeWASI", "worker_threads", "worker_threads.Worker", "onLoad"]:
idx = data.find(needle)
print(f"\n--- first {needle!r} at byte {idx} ---")
if idx == -1:
print(none)
else:
lines = data[:idx].count("\n")
chunk = data.splitlines()[max(0, lines-15):lines+20]
for i, line in enumerate(chunk, lines-14):
print(f"{i+1:4}: {line}")
PYRepository: tailwindlabs/tailwindcss Length of output: 2506 🌐 Web query:
💡 Result: In the NAPI-RS ecosystem, Citations:
🌐 Web query:
💡 Result: The Citations:
🏁 Script executed: #!/bin/bash
set -uo pipefail
echo "== exact worker template source from CDN, ignoring local TLS =="
python3 - <<'PY'
import urllib.request, ssl
url = "https://unpkg.com/@napi-rs/cli@3.7.4/files/src/api/templates/wasi-worker-template.ts"
ctx = ssl._create_unverified_context()
text = urllib.request.urlopen(url, context=ctx, timeout=20).read().decode()
lines = text.splitlines()
for needle in ["load-wasi", "WASI", "MessageHandler", "instantiateNapiModuleSync", "worker_threads", "ChildThread"]:
print(f"\n--- occurrences of {needle!r} ---")
for i, line in enumerate(lines, 1):
if needle in line:
start = max(1, i-8); end = min(len(lines), i+25)
print(f"{needle!r} at lines {start}-{end}")
for j in range(start, end+1):
print(f"{j:4}: {lines[j-1]}")
break
PY
echo
echo "== load-wasi template =="
python3 - <<'PY'
import urllib.request, ssl
url = "https://unpkg.com/@napi-rs/cli@3.7.4/files/src/api/templates/load-wasi-template.ts"
ctx = ssl._create_unverified_context()
text = urllib.request.urlopen(url, context=ctx, timeout=20).read().decode()
for i, line in enumerate(text.splitlines(), 1):
print(f"{i:4}: {line}")
PYRepository: tailwindlabs/tailwindcss Length of output: 1824 Cover both generated WASI construction paths in the worker shim. This test runs |
||
| 'src/index.js': js` | ||
| const className = "content-['src/index.js']" | ||
| module.exports = { className } | ||
| `, | ||
| 'index.mjs': js` | ||
| import { createRequire } from 'node:module' | ||
| import { join } from 'node:path' | ||
|
|
||
| let require = createRequire(import.meta.url) | ||
| let { Scanner } = require('@tailwindcss/oxide') | ||
|
|
||
| let loaded = Object.keys(require.cache) | ||
|
|
||
| let scanner = new Scanner({ | ||
| sources: [ | ||
| { | ||
| base: join(process.cwd(), 'src'), | ||
| pattern: '**/*', | ||
| negated: false, | ||
| }, | ||
| ], | ||
| }) | ||
|
|
||
| console.log( | ||
| JSON.stringify({ | ||
| wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')), | ||
| candidates: scanner.scan(), | ||
| }), | ||
| ) | ||
| process.exit() | ||
| `, | ||
| }, | ||
| }, | ||
| async ({ expect, exec }) => { | ||
| // See the note about NODE_PATH in the test above. | ||
| let output = await exec(`node --require ./preload.cjs index.mjs`, { env: { NODE_PATH: '' } }) | ||
|
|
||
| // Only parse the first line, because Node prints an `ExperimentalWarning` | ||
| // about WASI to stderr, which `exec` appends to the output. | ||
| let { wasi, candidates } = JSON.parse(output.trim().split('\n')[0]) | ||
|
|
||
| // The wasm32-wasi binding loaded despite `/` being denied, ... | ||
| expect(wasi).toBe(true) | ||
|
|
||
| // ... and scanning files under the current working directory still works | ||
| // through the narrower preopen. | ||
| expect(candidates).toMatchInlineSnapshot(` | ||
| [ | ||
| "className", | ||
| "const", | ||
| "content-['src/index.js']", | ||
| "exports", | ||
| ] | ||
| `) | ||
| }, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| diff --git a/dist/cli.js b/dist/cli.js | ||
| index c4c2d568bc070c07dbf3a1f9f4a0c946466668cf..353bbca86e3261a2ba9a8fc56dc612c7c84dab6d 100755 | ||
| --- a/dist/cli.js | ||
| +++ b/dist/cli.js | ||
| @@ -1,4 +1,13 @@ | ||
| #!/usr/bin/env node | ||
| +// PATCHED (see patches/@napi-rs__cli@3.7.4.patch): the embedded wasi loader | ||
| +// and worker templates below retry `new WASI(...)` with narrower preopens, | ||
| +// because preopening `/` throws `UVWASI_EACCES` on sandboxed platforms (e.g. | ||
| +// OpenHarmony), which would make the generated wasm binding fail to load. | ||
| +// | ||
| +// The same templates also exist in `dist/index.js` and `dist/index.cjs` (the | ||
| +// programmatic API). Those are intentionally NOT patched because we only | ||
| +// build through the `napi` bin, which runs this file. If we ever start using | ||
| +// the programmatic API, update the patch to cover those bundles too. | ||
| import { createRequire } from "node:module"; | ||
| import { Cli, Command, Option } from "clipanion"; | ||
| import path, { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; | ||
| @@ -1021,13 +1030,25 @@ const { | ||
|
|
||
| const __rootDir = __nodePath.parse(process.cwd()).root | ||
|
|
||
| -const __wasi = new __nodeWASI({ | ||
| - version: 'preview1', | ||
| - env: process.env, | ||
| - preopens: { | ||
| - [__rootDir]: __rootDir, | ||
| +const __wasi = (() => { | ||
| + // Preopening '/' fails with UVWASI_EACCES in sandboxed environments (e.g. | ||
| + // OpenHarmony, Android), which would prevent the wasm binding from loading | ||
| + // at all. Retry with narrower preopens instead. Without any preopens the | ||
| + // binding still loads; only file system access is unavailable. | ||
| + let lastError = null | ||
| + for (const dir of [__rootDir, process.cwd(), null]) { | ||
| + try { | ||
| + return new __nodeWASI({ | ||
| + version: 'preview1', | ||
| + env: process.env, | ||
| + preopens: dir === null ? {} : { [dir]: dir }, | ||
| + }) | ||
| + } catch (error) { | ||
| + lastError = error | ||
| + } | ||
| } | ||
| -}) | ||
| + throw lastError | ||
| +})() | ||
|
|
||
| const __emnapiContext = __emnapiGetDefaultContext() | ||
|
|
||
| @@ -1151,13 +1172,22 @@ const __rootDir = parse(process.cwd()).root; | ||
|
|
||
| const handler = new MessageHandler({ | ||
| onLoad({ wasmModule, wasmMemory }) { | ||
| - const wasi = new WASI({ | ||
| - version: 'preview1', | ||
| - env: process.env, | ||
| - preopens: { | ||
| - [__rootDir]: __rootDir, | ||
| - }, | ||
| - }); | ||
| + // Keep in sync with the preopen fallback in the main-thread loader. | ||
| + const wasi = (() => { | ||
| + let lastError = null; | ||
| + for (const dir of [__rootDir, process.cwd(), null]) { | ||
| + try { | ||
| + return new WASI({ | ||
| + version: 'preview1', | ||
| + env: process.env, | ||
| + preopens: dir === null ? {} : { [dir]: dir }, | ||
| + }); | ||
| + } catch (error) { | ||
| + lastError = error; | ||
| + } | ||
| + } | ||
| + throw lastError; | ||
| + })(); | ||
|
|
||
| return instantiateNapiModuleSync(wasmModule, { | ||
| childThread: true, |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.