diff --git a/packages/php-wasm/compile/imagick/Dockerfile b/packages/php-wasm/compile/imagick/Dockerfile new file mode 100644 index 0000000000..6eaa43fd32 --- /dev/null +++ b/packages/php-wasm/compile/imagick/Dockerfile @@ -0,0 +1,137 @@ +FROM playground-php-wasm:base + +# Each version of PHP requires its own version of Imagick +# because each version of PHP has a specific Zend Engine API version. +# Consequently, each version of Imagick is designed to be +# compatible with a particular Zend Engine API version. +ARG PHP_VERSION + +ARG WITH_JSPI + +ARG WITH_DEBUG + +# Temporary install vim +RUN apt-get update && apt-get install -y vim + +# Install Bison, required to build PHP +RUN mkdir -p /libs +COPY ./bison2.7/dist/ /libs/bison2.7 +COPY ./bison2.7/bison27.patch /root/bison27.patch + +RUN if [[ "${PHP_VERSION:0:1}" -le "7" && "${PHP_VERSION:2:1}" -le "3" ]]; then \ + if /libs/bison2.7/usr/local/bison/bin/bison -h >/dev/null; then \ + mv /libs/bison2.7/usr/local/bison /usr/local/bison && \ + ln -s /usr/local/bison/bin/bison /usr/bin/bison && \ + ln -s /usr/local/bison/bin/yacc /usr/bin/yacc; \ + else \ + wget https://ftp.gnu.org/gnu/bison/bison-2.7.tar.gz && \ + tar -xvf bison-2.7.tar.gz && \ + rm bison-2.7.tar.gz && \ + cd bison-2.7 && \ + git apply --no-index /root/bison27.patch && \ + ./configure --prefix=/usr/local && \ + make && \ + make install; \ + if [[ $? -ne 0 ]]; then \ + echo 'Failed to build Bison 2.7 dependency.'; \ + exit -1; \ + fi; \ + fi; \ + else \ + apt install -y bison; \ + fi; + +# Build PHP +RUN git clone https://github.com/php/php-src.git php-src \ + --branch PHP-$PHP_VERSION \ + --single-branch \ + --depth 1; + +RUN cd php-src && ./buildconf --force + +# PHP <= 7.3 Get and patch PHP +COPY ./php/php*.patch /root/ +RUN cd /root && git apply --no-index /root/php${PHP_VERSION:0:3}*.patch -v + +RUN source /root/emsdk/emsdk_env.sh && \ + cd php-src && \ + emconfigure ./configure \ + --disable-fiber-asm \ + --enable-embed \ + --disable-cgi \ + --disable-opcache \ + --disable-phpdbg \ + --without-pcre-jit \ + --disable-cli \ + --disable-libxml \ + --without-libxml \ + --disable-dom \ + --disable-xml \ + --disable-simplexml \ + --disable-xmlreader \ + --disable-xmlwriter \ + --without-sqlite3 \ + --without-pdo-sqlite \ + # PHP 7.4 + --without-iconv + +# Disable asm arithmetic. +RUN if [[ ("${PHP_VERSION:0:1}" -eq "7" && "${PHP_VERSION:2:1}" -ge "4") || "${PHP_VERSION:0:1}" -ge "8" ]]; then \ + /root/replace.sh 's/ZEND_USE_ASM_ARITHMETIC 1/ZEND_USE_ASM_ARITHMETIC 0/g' /root/php-src/Zend/zend_operators.h; \ + elif [[ "${PHP_VERSION:0:1}" -eq "7" && "${PHP_VERSION:2:1}" -eq "3" ]]; then \ + /root/replace.sh 's/defined\(HAVE_ASM_GOTO\)/0/g' /root/php-src/Zend/zend_operators.h; \ + fi; +RUN /root/replace.sh 's/defined\(__GNUC__\)/0/g' /root/php-src/Zend/zend_multiply.h +RUN /root/replace.sh 's/defined\(__GNUC__\)/0/g' /root/php-src/Zend/zend_cpuinfo.c +RUN /root/replace.sh 's/defined\(__clang__\)/0/g' /root/php-src/Zend/zend_cpuinfo.c + +# PHP <= 7.3 is not very good at detecting the presence of the POSIX readdir_r function +# so we need to force it to be enabled. +RUN if [[ "${PHP_VERSION:0:1}" -le "7" && "${PHP_VERSION:2:1}" -le "3" ]]; then \ + echo '#define HAVE_POSIX_READDIR_R 1' >> /root/php-src/main/php_config.h; \ + fi; + +RUN source /root/emsdk/emsdk_env.sh && \ + cd /root/php-src && \ + emmake make install + + +# Build ImageMagick library +RUN cd /root/ ; \ + wget https://imagemagick.org/archive/ImageMagick.tar.gz; \ + tar -xvf ImageMagick.tar.gz; \ + rm ImageMagick.tar.gz; \ + cd ImageMagick-*; \ + source /root/emsdk/emsdk_env.sh; \ + # Disable functions that are not supported by Emscripten + ## TODO: Check if we can support these functions + ac_cv_func_vfprintf_l=no \ + ac_cv_func_vsnprintf_l=no \ + ac_cv_func_getexecname=no \ + ac_cv_func__NSGetExecutablePath=no \ + ac_cv_func_getdtablesize=no \ + emconfigure ./configure \ + # Disable X11 support + --without-x \ + --host="i386-unknown-freebsd" \ + --disable-static \ + --enable-shared; \ + emmake make -j1; \ + emmake make install; + +# Build Imagick + +RUN cd /root/ ; \ + wget https://github.com/Imagick/imagick/archive/refs/heads/master.zip; \ + unzip master.zip; \ + rm master.zip; \ + cd imagick-master; \ + phpize . ; \ + source /root/emsdk/emsdk_env.sh; \ + emconfigure ./configure \ + --disable-static \ + --enable-shared; \ + export EMCC_FLAGS="-sSIDE_MODULE=2"; \ + emmake make -j1 + + diff --git a/packages/php-wasm/compile/imagick/build.js b/packages/php-wasm/compile/imagick/build.js new file mode 100644 index 0000000000..1ec777c515 --- /dev/null +++ b/packages/php-wasm/compile/imagick/build.js @@ -0,0 +1,130 @@ +import path from 'path'; +import { spawn } from 'child_process'; +import { phpVersions } from '../../supported-php-versions.mjs'; + +// yargs parse +import yargs from 'yargs'; +const argParser = yargs(process.argv.slice(2)) + .usage('Usage: $0 [options]') + .options({ + PHP_VERSION: { + type: 'string', + description: 'The PHP version to build', + required: true, + }, + ['output-dir']: { + type: 'string', + description: 'The output directory', + required: true, + }, + WITH_DEBUG: { + type: 'string', + choices: ['yes', 'no'], + description: 'Build with DWARF debug information.', + }, + WITH_JSPI: { + type: 'boolean', + default: false, + description: 'Build with JSPI support', + }, + }); + +const args = argParser.argv; + +const platformDefaults = { + all: { + PHP_VERSION: '8.0.24', + WITH_DEBUG: 'no', + WITH_JSPI: 'no', + }, +}; + +const getArg = (name) => { + let value = + name in args + ? args[name] + : name in platformDefaults.all + ? platformDefaults.all[name] + : 'no'; + if (name === 'PHP_VERSION') { + value = fullyQualifiedPHPVersion(value); + } + return `${name}=${value}`; +}; + +const requestedVersion = getArg('PHP_VERSION'); +if (!requestedVersion || requestedVersion === 'undefined') { + process.stdout.write(`PHP version ${requestedVersion} is not supported\n`); + process.stdout.write(await argParser.getHelp()); + process.exit(1); +} + +const sourceDir = path.dirname(new URL(import.meta.url).pathname); +const outputDir = path.resolve(process.cwd(), args.outputDir); + +// Build the base image +await asyncSpawn('make', ['base-image'], { + cwd: path.dirname(sourceDir), + stdio: 'inherit', +}); + +// Build the xdebug.so extension +await asyncSpawn( + 'docker', + [ + 'build', + '-f', + 'imagick/Dockerfile', + '.', + '--tag=playground-php-wasm:imagick', + '--progress=plain', + '--build-arg', + getArg('PHP_VERSION'), + '--build-arg', + getArg('WITH_DEBUG'), + '--build-arg', + getArg('WITH_JSPI'), + ], + { cwd: path.dirname(sourceDir), stdio: 'inherit' } +); + +const version = args['PHP_VERSION'].replace('.', '_'); + +await asyncSpawn( + 'docker', + [ + 'run', + '--name', + 'playground-php-wasm-tmp', + '--rm', + '-v', + `${outputDir}:/output`, + 'playground-php-wasm:imagick', + // Use sh -c because wildcards are a shell feature and + // they don't work without running cp through shell. + 'sh', + '-c', + `mkdir -p /output/extensions/imagick/${version} && cp -rf /root/imagick-master/modules/imagick.so /output/extensions/imagick/${version}`, + ], + { cwd: path.dirname(sourceDir), stdio: 'inherit' } +); + +function asyncSpawn(...args) { + console.log('Running', args[0], args[1].join(' '), '...'); + return new Promise((resolve, reject) => { + const child = spawn(...args); + child.on('close', (code) => { + if (code === 0) resolve(code); + else reject(new Error(`Process exited with code ${code}`)); + }); + }); +} + +function fullyQualifiedPHPVersion(requestedVersion) { + for (const { version, lastRelease } of phpVersions) { + if (requestedVersion === version) { + return lastRelease; + } + } + return requestedVersion; +} diff --git a/packages/php-wasm/compile/project.json b/packages/php-wasm/compile/project.json index 522fceefb6..60bfa2538f 100644 --- a/packages/php-wasm/compile/project.json +++ b/packages/php-wasm/compile/project.json @@ -58,6 +58,15 @@ ], "parallel": false } + }, + "imagick:jspi:8.3": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "node packages/php-wasm/compile/imagick/build.js --output-dir=packages/php-wasm/node/jspi --WITH_JSPI=yes --PHP_VERSION=8.3" + ], + "parallel": false + } } }, "tags": [] diff --git a/packages/php-wasm/node/jspi/extensions/imagick/8_3/imagick.so b/packages/php-wasm/node/jspi/extensions/imagick/8_3/imagick.so new file mode 100755 index 0000000000..03e677c580 Binary files /dev/null and b/packages/php-wasm/node/jspi/extensions/imagick/8_3/imagick.so differ diff --git a/packages/php-wasm/node/project.json b/packages/php-wasm/node/project.json index 01400f6beb..daaafc32c0 100644 --- a/packages/php-wasm/node/project.json +++ b/packages/php-wasm/node/project.json @@ -184,11 +184,19 @@ "write-files.spec.ts", "php-networking.spec.ts", "php-dynamic-loading.spec.ts", - "php-request-handler.spec.ts", - "php.spec.ts" + "php-request-handler.spec.ts" ] } }, + "test-jspi-imagick": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/packages/php-wasm/node"], + "options": { + "configFile": "packages/php-wasm/node/vite.jspi.config.ts", + "reportsDirectory": "../../../coverage/packages/php-wasm/node", + "testFiles": ["imagick.spec.ts"] + } + }, "test-php-file-get-contents-asyncify": { "executor": "@nx/vite:test", "outputs": ["{workspaceRoot}/coverage/packages/php-wasm/node"], diff --git a/packages/php-wasm/node/src/lib/imagick/get-imagick-extension-module.ts b/packages/php-wasm/node/src/lib/imagick/get-imagick-extension-module.ts new file mode 100644 index 0000000000..783c1334ff --- /dev/null +++ b/packages/php-wasm/node/src/lib/imagick/get-imagick-extension-module.ts @@ -0,0 +1,138 @@ +import { LatestSupportedPHPVersion } from '@php-wasm/universal'; +import type { SupportedPHPVersion } from '@php-wasm/universal'; +import { jspi } from 'wasm-feature-detect'; + +export async function getImagickExtensionModule( + version: SupportedPHPVersion = LatestSupportedPHPVersion +): Promise { + /** + * Hack: Keeping the path working in both + * the source file and the final bundle requires + * ESBuild and Vite to rewrite the below path. + * Vite will return the imagick extension's + * absolute path during tests while ESBuild + * returns a resolved path between __dirname and + * the extension's relative path during build + * since target directories are not identically + * located in built and unbuilt versions. + */ + if (await jspi()) { + switch (version) { + case '8.4': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/8_4/imagick.so?url` + ) + ).default; + case '8.3': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/8_3/imagick.so?url` + ) + ).default; + case '8.2': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/8_2/imagick.so?url` + ) + ).default; + case '8.1': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/8_1/imagick.so?url` + ) + ).default; + case '8.0': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/8_0/imagick.so?url` + ) + ).default; + case '7.4': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/7_4/imagick.so?url` + ) + ).default; + case '7.3': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/7_3/imagick.so?url` + ) + ).default; + case '7.2': + return ( + await import( + // @ts-ignore + `../../../jspi/extensions/imagick/7_2/imagick.so?url` + ) + ).default; + } + } else { + switch (version) { + case '8.4': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/8_4/imagick.so?url` + ) + ).default; + case '8.3': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/8_3/imagick.so?url` + ) + ).default; + case '8.2': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/8_2/imagick.so?url` + ) + ).default; + case '8.1': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/8_1/imagick.so?url` + ) + ).default; + case '8.0': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/8_0/imagick.so?url` + ) + ).default; + case '7.4': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/7_4/imagick.so?url` + ) + ).default; + case '7.3': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/7_3/imagick.so?url` + ) + ).default; + case '7.2': + return ( + await import( + // @ts-ignore + `../../../asyncify/extensions/imagick/7_2/imagick.so?url` + ) + ).default; + } + } +} diff --git a/packages/php-wasm/node/src/lib/imagick/with-imagick.ts b/packages/php-wasm/node/src/lib/imagick/with-imagick.ts new file mode 100644 index 0000000000..e64c37af59 --- /dev/null +++ b/packages/php-wasm/node/src/lib/imagick/with-imagick.ts @@ -0,0 +1,86 @@ +import type { + EmscriptenOptions, + PHPRuntime, + SupportedPHPVersion, +} from '@php-wasm/universal'; +import { LatestSupportedPHPVersion, FSHelpers } from '@php-wasm/universal'; +import fs from 'fs'; +import { getImagickExtensionModule } from './get-imagick-extension-module'; + +export async function withImagick( + version: SupportedPHPVersion = LatestSupportedPHPVersion, + options: EmscriptenOptions +): Promise { + const fileName = 'imagick.so'; + const filePath = await getImagickExtensionModule(version); + const extension = fs.readFileSync(filePath); + + return { + ...options, + ENV: { + ...options.ENV, + PHP_INI_SCAN_DIR: '/internal/shared/extensions', + }, + onRuntimeInitialized: (phpRuntime: PHPRuntime) => { + if (options.onRuntimeInitialized) { + options.onRuntimeInitialized(phpRuntime); + } + /** + * The extension file previously read + * is written inside the /extensions directory + */ + if ( + !FSHelpers.fileExists( + phpRuntime.FS, + '/internal/shared/extensions' + ) + ) { + phpRuntime.FS.mkdirTree('/internal/shared/extensions'); + } + if ( + !FSHelpers.fileExists( + phpRuntime.FS, + `/internal/shared/extensions/${fileName}` + ) + ) { + phpRuntime.FS.writeFile( + `/internal/shared/extensions/${fileName}`, + new Uint8Array(extension) + ); + } + /* The extension has its share of ini entries + * to write in a separate ini file + */ + if ( + !FSHelpers.fileExists( + phpRuntime.FS, + '/internal/shared/extensions/imagick.ini' + ) + ) { + phpRuntime.FS.writeFile( + '/internal/shared/extensions/imagick.ini', + [`extension=/internal/shared/extensions/${fileName}`].join( + '\n' + ) + ); + } + /* The extension needs to mount the current + * working directory in order to sync with + * the debugger. + * This is currently the base step but + * we may mount any path – cwd or not cwd. + * We may also mount multiple paths in different locations, + * or we may not mount any paths at all and just write a + * bunch of PHP files into /wordpress, e.g. + * when executing a Blueprint. + */ + phpRuntime.FS.mkdirTree(process.cwd()); + phpRuntime.FS.mount( + phpRuntime.FS.filesystems['NODEFS'], + { root: process.cwd() }, + process.cwd() + ); + phpRuntime.FS.chdir(process.cwd()); + }, + }; +} diff --git a/packages/php-wasm/node/src/lib/load-runtime.ts b/packages/php-wasm/node/src/lib/load-runtime.ts index 961987b748..385ae1912c 100644 --- a/packages/php-wasm/node/src/lib/load-runtime.ts +++ b/packages/php-wasm/node/src/lib/load-runtime.ts @@ -13,11 +13,13 @@ import { withICUData } from './data/with-icu-data'; import { withXdebug } from './xdebug/with-xdebug'; import { joinPaths } from '@php-wasm/util'; import { dirname } from 'path'; +import { withImagick } from './imagick/with-imagick'; export interface PHPLoaderOptions { emscriptenOptions?: EmscriptenOptions; followSymlinks?: boolean; withXdebug?: boolean; + withImagick?: boolean; } type PHPLoaderOptionsForNode = PHPLoaderOptions & { @@ -183,6 +185,9 @@ export async function loadNodeRuntime( if (options?.withXdebug === true) { emscriptenOptions = await withXdebug(phpVersion, emscriptenOptions); } + if (options?.withImagick === true) { + emscriptenOptions = await withImagick(phpVersion, emscriptenOptions); + } emscriptenOptions = await withICUData(emscriptenOptions); emscriptenOptions = await withNetworking(emscriptenOptions); diff --git a/packages/php-wasm/node/src/test/imagick.spec.ts b/packages/php-wasm/node/src/test/imagick.spec.ts new file mode 100644 index 0000000000..0634b44ee6 --- /dev/null +++ b/packages/php-wasm/node/src/test/imagick.spec.ts @@ -0,0 +1,32 @@ +import { PHP, setPhpIniEntries } from '@php-wasm/universal'; +import { loadNodeRuntime } from '../lib'; + +describe('imagick', () => { + let php: PHP; + beforeEach(async () => { + php = new PHP(await loadNodeRuntime('8.3', { withImagick: true })); + }); + + it('supports dynamic loading', async () => { + const result = await php.runStream({ + code: ` { + const image = await php.run({ + code: `thumbnailImage(100, 0); + echo $image; + `, + }); + + console.log(image.text); + expect(false).toBe(true); + }); +});