diff --git a/crates/node/package.json b/crates/node/package.json index 71f126010..a3f71a0b9 100644 --- a/crates/node/package.json +++ b/crates/node/package.json @@ -32,6 +32,10 @@ } }, "license": "MIT", + "dependencies": { + "tar": "^7.4.3", + "detect-libc": "^2.0.4" + }, "devDependencies": { "@napi-rs/cli": "^3.0.0-alpha.78", "@napi-rs/wasm-runtime": "^0.2.9", @@ -42,7 +46,8 @@ }, "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "scripts/install.js" ], "publishConfig": { "provenance": true, @@ -57,7 +62,8 @@ "postbuild:wasm": "node ./scripts/move-artifacts.mjs", "dev": "cargo watch --quiet --shell 'npm run build'", "build:debug": "napi build --platform --no-const-enum", - "version": "napi version" + "version": "napi version", + "postinstall": "node ./scripts/install.js" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "workspace:*", diff --git a/crates/node/scripts/install.js b/crates/node/scripts/install.js new file mode 100644 index 000000000..f9cefe01c --- /dev/null +++ b/crates/node/scripts/install.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * @tailwindcss/oxide postinstall script + * + * This script ensures that the correct binary for the current platform and + * architecture is downloaded and available. + */ + +const fs = require('fs') +const path = require('path') +const https = require('https') +const { extract } = require('tar') +const packageJson = require('../package.json') +const detectLibc = require('detect-libc') + +const version = packageJson.version + +function getPlatformPackageName() { + let platform = process.platform + let arch = process.arch + + let libc = '' + if (platform === 'linux') { + libc = detectLibc.isNonGlibcLinuxSync() ? 'musl' : 'gnu' + } + + // Map to our package naming conventions + switch (platform) { + case 'darwin': + return arch === 'arm64' ? '@tailwindcss/oxide-darwin-arm64' : '@tailwindcss/oxide-darwin-x64' + case 'win32': + if (arch === 'arm64') return '@tailwindcss/oxide-win32-arm64-msvc' + if (arch === 'ia32') return '@tailwindcss/oxide-win32-ia32-msvc' + return '@tailwindcss/oxide-win32-x64-msvc' + case 'linux': + if (arch === 'x64') { + return libc === 'musl' + ? '@tailwindcss/oxide-linux-x64-musl' + : '@tailwindcss/oxide-linux-x64-gnu' + } else if (arch === 'arm64') { + return libc === 'musl' + ? '@tailwindcss/oxide-linux-arm64-musl' + : '@tailwindcss/oxide-linux-arm64-gnu' + } else if (arch === 'arm') { + return '@tailwindcss/oxide-linux-arm-gnueabihf' + } + break + case 'freebsd': + return '@tailwindcss/oxide-freebsd-x64' + case 'android': + return '@tailwindcss/oxide-android-arm64' + default: + return '@tailwindcss/oxide-wasm32-wasi' + } +} + +function isPackageAvailable(packageName) { + try { + require.resolve(packageName) + return true + } catch (e) { + return false + } +} + +// Extract all files from a tarball to a destination directory +async function extractTarball(tarballStream, destDir) { + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }) + } + + return new Promise((resolve, reject) => { + tarballStream + .pipe(extract({ cwd: destDir, strip: 1 })) + .on('error', (err) => reject(err)) + .on('end', () => resolve()) + }) +} + +async function downloadAndExtractBinary(packageName) { + let tarballUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace('@tailwindcss/', '')}-${version}.tgz` + console.log(`Downloading ${tarballUrl}...`) + + return new Promise((resolve) => { + https + .get(tarballUrl, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Handle redirects + https.get(response.headers.location, handleResponse).on('error', (err) => { + console.error('Download error:', err) + resolve() + }) + return + } + + handleResponse(response) + + async function handleResponse(response) { + try { + if (response.statusCode !== 200) { + throw new Error(`Download failed with status code: ${response.statusCode}`) + } + + await extractTarball( + response, + path.join(__dirname, '..', 'node_modules', ...packageName.split('/')), + ) + console.log(`Successfully downloaded and installed ${packageName}`) + } catch (error) { + console.error('Error during extraction:', error) + resolve() + } finally { + resolve() + } + } + }) + .on('error', (err) => { + console.error('Download error:', err) + resolve() + }) + }) +} + +async function main() { + // Don't run this script in the package source + try { + if (fs.existsSync(path.join(__dirname, '..', 'build.rs'))) { + return + } + + let packageName = getPlatformPackageName() + if (!packageName) return + if (isPackageAvailable(packageName)) return + + await downloadAndExtractBinary(packageName) + } catch (error) { + console.error(error) + return + } +} + +main() diff --git a/integrations/oxide/postinstall.test.ts b/integrations/oxide/postinstall.test.ts new file mode 100644 index 000000000..70683c82e --- /dev/null +++ b/integrations/oxide/postinstall.test.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { js, json, test } from '../utils' + +test( + '@tailwindcss/oxide will fail when architecture-specific packages are missing', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/oxide": "workspace:^" + } + } + `, + 'test.js': js` + try { + let Scanner = require('@tailwindcss/oxide') + console.log('SUCCESS: @tailwindcss/oxide loaded successfully', Scanner) + } catch (error) { + console.log('FAILURE: Failed to load @tailwindcss/oxide:', error.message) + } + `, + }, + }, + async ({ exec, root, expect, fs }) => { + await removePlatformSpecificExtensions(path.join(root, 'node_modules')) + + // Get last published version + let version = (await exec('npm show @tailwindcss/oxide version')).trim() + // Ensure that we don't depend on a specific version number in the download + // script in case we bump the version number in the repository and CI is run + // before a release + let packageJson = JSON.parse(await fs.read('node_modules/@tailwindcss/oxide/package.json')) + packageJson.version = version + await fs.write( + 'node_modules/@tailwindcss/oxide/package.json', + JSON.stringify(packageJson, null, 2), + ) + + let opts = { + // Ensure that we don't include any node paths from the test runner + env: { NODE_PATH: '' }, + } + + expect(await exec('node test.js', opts)).toMatch(/FAILURE/) + + // Now run the post-install script + await exec('node node_modules/@tailwindcss/oxide/scripts/install.js', opts) + + expect(await exec('node test.js', opts)).toMatch(/SUCCESS/) + }, +) + +async function removePlatformSpecificExtensions(directory: string) { + let entries = await fs.readdir(directory, { withFileTypes: true }) + + for (let entry of entries) { + let fullPath = path.join(directory, entry.name) + + if (entry.name.startsWith('oxide-')) { + if (entry.isSymbolicLink()) { + await fs.unlink(fullPath) + } else if (entry.isFile()) { + await fs.unlink(fullPath) + } else if (entry.isDirectory()) { + await fs.rm(fullPath, { recursive: true, force: true }) + } + } else if (entry.isDirectory()) { + await removePlatformSpecificExtensions(fullPath) + } + } +} diff --git a/integrations/utils.ts b/integrations/utils.ts index f13e8581e..03c4c4e71 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -126,7 +126,10 @@ export function test( { cwd, ...childProcessOptions, - env: childProcessOptions.env, + env: { + ...process.env, + ...childProcessOptions.env, + }, }, (error, stdout, stderr) => { if (error) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d53ebd3..b1eb22cd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,13 @@ importers: version: 2.0.5(@types/node@20.14.13)(lightningcss@1.29.2(patch_hash=tzyxy3asfxcqc7ihrooumyi5fm))(terser@5.31.6) crates/node: + dependencies: + detect-libc: + specifier: ^2.0.4 + version: 2.0.4 + tar: + specifier: ^7.4.3 + version: 7.4.3 optionalDependencies: '@tailwindcss/oxide-android-arm64': specifier: workspace:* @@ -1546,6 +1553,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -2690,6 +2701,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -2846,8 +2861,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} didyoumean@1.2.2: @@ -3641,6 +3656,15 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.0.2: + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mlly@1.7.3: resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==} @@ -4233,6 +4257,10 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + terser@5.31.6: resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==} engines: {node: '>=10'} @@ -4616,6 +4644,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.6.0: resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} engines: {node: '>= 14'} @@ -5309,6 +5341,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -6404,6 +6440,8 @@ snapshots: dependencies: readdirp: 4.1.1 + chownr@3.0.0: {} + citty@0.1.6: dependencies: consola: 3.2.3 @@ -6533,7 +6571,7 @@ snapshots: detect-libc@1.0.3: {} - detect-libc@2.0.3: {} + detect-libc@2.0.4: {} didyoumean@1.2.2: {} @@ -7492,7 +7530,7 @@ snapshots: lightningcss@1.29.2(patch_hash=tzyxy3asfxcqc7ihrooumyi5fm): dependencies: - detect-libc: 2.0.3 + detect-libc: 2.0.4 optionalDependencies: lightningcss-darwin-arm64: 1.29.2 lightningcss-darwin-x64: 1.29.2 @@ -7598,6 +7636,12 @@ snapshots: minipass@7.1.2: {} + minizlib@3.0.2: + dependencies: + minipass: 7.1.2 + + mkdirp@3.0.1: {} + mlly@1.7.3: dependencies: acorn: 8.14.0 @@ -8040,7 +8084,7 @@ snapshots: sharp@0.34.1: dependencies: color: 4.2.3 - detect-libc: 2.0.3 + detect-libc: 2.0.4 semver: 7.7.1 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.1 @@ -8233,6 +8277,15 @@ snapshots: tapable@2.2.1: {} + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.2 + mkdirp: 3.0.1 + yallist: 5.0.0 + terser@5.31.6: dependencies: '@jridgewell/source-map': 0.3.6 @@ -8623,6 +8676,8 @@ snapshots: yallist@3.1.1: {} + yallist@5.0.0: {} + yaml@2.6.0: {} yocto-queue@0.1.0: {}