Use wasm as a fallback for @tailwindcss/oxide (#20383)
Right now, we use Rust for `@tailwindcss/oxide` which has 2 responsibilities: 1. Traverse the file system and figure out which files need to be scanned based on auto source detection and `@source` directives. 2. Given those files, extract possible Tailwind CSS classes which we call candidates. Since this is using native code, we use napi-rs to get native `.node` files on a per platform / arch basis. So far so good, however, if you are on an OS that doesn't have a prebuilt binary, you will receive an error that might look like this: ``` Error: Cannot find native binding. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory. at Object.<anonymous> (/private/var/folders/1k/bdv8blv93xq7qgwjdwc9z88h0000gn/T/tailwind-integrationspYHIVP/node_modules/.pnpm/@tailwindcss+oxide@file+..+..+..+..+..+..+..+Users+robin+github.com+tailwindlabs+tailwi_47ae1688f61c719f66c73e2ff35e430f/node_modules/@tailwindcss/oxide/index.js:573:19) at Module._compile (node:internal/modules/cjs/loader:1829:14) at Object..js (node:internal/modules/cjs/loader:1969:10) at Module.load (node:internal/modules/cjs/loader:1552:32) at Module._load (node:internal/modules/cjs/loader:1354:12) at wrapModuleLoad (node:internal/modules/cjs/loader:255:19) at Module.require (node:internal/modules/cjs/loader:1575:12) at require (node:internal/modules/helpers:191:16) at file:///private/var/folders/1k/bdv8blv93xq7qgwjdwc9z88h0000gn/T/tailwind-integrationspYHIVP/index.mjs:5:19 at ModuleJob.run (node:internal/modules/esm/module_job:437:25) { cause: Error: Cannot find module '@tailwindcss/oxide-darwin-arm64' ``` This means that we have to add support for these platforms, and there are some open PRs related to this, which could be closed by this PR: - #20327 - #20276 - #20201 Today we already have support for the big platforms out there: - Windows arm64 - Windows x64 - macOS arm64 - macOS x64 But then it starts to get a bit out of hand once we start looking at Linux based versions: - Android arm eabi - Android arm64 - Linux arm64 gnu - Linux arm64 gnueabihf - Linux arm64 musl - Linux x64 gnu - Linux x64 musl - freebsd x64 ... and then we have the pending list from the 3 PRs linked above. Adding support for all of these is not the end of the world, but it gets complex if we need to keep supporting more and more. Right now we rely on a bunch of non-default napi-rs setup in CI just to support these other platforms. This PR solves that by using the `wasm32-wasi` build as a universal fallback. The napi-rs generated loader already knows how to fall back to `@tailwindcss/oxide-wasm32-wasi`, but that package declared `"cpu": ["wasm32"]`, so npm/pnpm never installed it on real hardware. Removing that restriction means the package is installed everywhere, and the loader picks it up whenever no native binding exists. This PR also fixes a `UVWASI_EACCES` crash on sandboxed platforms (OpenHarmony, Android): the generated wasm loader preopens `/`, which those sandboxes deny, so the fallback failed to load on exactly the platforms that need it (see [this comment](https://github.com/tailwindlabs/tailwindcss/pull/20276#issuecomment-4950167198)). We patch `@napi-rs/cli`'s codegen templates via `pnpm patch` to retry with narrower preopens (`/` → cwd → none). On such platforms, scanning is limited to files under the current working directory. ## Test plan Added two integration tests: 1. Trick pnpm (via `supportedArchitectures`) into installing for a platform we explicitly don't support, and assert `@tailwindcss/oxide` loads the wasm binding and scans files from disk. 2. Simulate a sandbox that denies preopening `/`, and assert the wasm binding still loads and scans. [ci-all]
This commit is contained in:
parent
05c3a87627
commit
d190343594
6 changed files with 270 additions and 7 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
{
|
||||
"name": "@tailwindcss/oxide-wasm32-wasi",
|
||||
"version": "4.3.3",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"main": "tailwindcss-oxide.wasi.cjs",
|
||||
"files": [
|
||||
"tailwindcss-oxide.wasm32-wasi.wasm",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
`,
|
||||
'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",
|
||||
]
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
80
patches/@napi-rs__cli@3.7.4.patch
Normal file
80
patches/@napi-rs__cli@3.7.4.patch
Normal file
|
|
@ -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,
|
||||
5
pnpm-lock.yaml
generated
5
pnpm-lock.yaml
generated
|
|
@ -77,6 +77,7 @@ catalogs:
|
|||
version: 5.109.2
|
||||
|
||||
patchedDependencies:
|
||||
'@napi-rs/cli@3.7.4': 9912bf0a9c2cef8329d11c41fbce4d33ce2bdcf660a630b258c962c0a3c44bed
|
||||
'@parcel/watcher@2.6.0': 705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da
|
||||
lightningcss@1.33.0: 1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930
|
||||
|
||||
|
|
@ -128,7 +129,7 @@ importers:
|
|||
version: 1.11.3
|
||||
'@napi-rs/cli':
|
||||
specifier: 3.7.4
|
||||
version: 3.7.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@25.9.1)(node-addon-api@8.7.0)
|
||||
version: 3.7.4(patch_hash=9912bf0a9c2cef8329d11c41fbce4d33ce2bdcf660a630b258c962c0a3c44bed)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@25.9.1)(node-addon-api@8.7.0)
|
||||
'@napi-rs/wasm-runtime':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
|
||||
|
|
@ -3895,7 +3896,7 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@napi-rs/cli@3.7.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@25.9.1)(node-addon-api@8.7.0)':
|
||||
'@napi-rs/cli@3.7.4(patch_hash=9912bf0a9c2cef8329d11c41fbce4d33ce2bdcf660a630b258c962c0a3c44bed)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@25.9.1)(node-addon-api@8.7.0)':
|
||||
dependencies:
|
||||
'@inquirer/prompts': 8.5.2(@types/node@25.9.1)
|
||||
'@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ packages:
|
|||
- 'integrations'
|
||||
|
||||
patchedDependencies:
|
||||
'@napi-rs/cli@3.7.4': patches/@napi-rs__cli@3.7.4.patch
|
||||
'@parcel/watcher@2.6.0': patches/@parcel__watcher@2.6.0.patch
|
||||
lightningcss@1.33.0: patches/lightningcss@1.33.0.patch
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue