diff --git a/.eslintignore b/.eslintignore index 34950a60fa..4acd2784e7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,6 +9,7 @@ packages/playground/wordpress-builds/public packages/playground/sync/src/test/wp-* packages/php-wasm/node/src/test/__test* *.timestamp-1678999213403.mjs +rollup.d.ts .local .vscode .nx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 986b3cb51e..6f2b1f91a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,23 +210,26 @@ jobs: jq --arg version "$VERSION" '.version = $version' "$package" > "$package.tmp" mv "$package.tmp" "$package" done - - name: Package repository + - name: Package repository and start a local node package registry server run: | npx nx run-many --all --target=package-for-self-hosting -- --hostingBaseUrl="$PACKAGE_BASE_URL" - - name: Start a local node package registry server - run: | - source ~/.nvm/nvm.sh; - nvm install 22; - RUNNER_TRACKING_ID="" && ( \ - nohup node \ - --experimental-strip-types \ - --experimental-transform-types \ - --import ./packages/meta/src/node-es-module-loader/register.mts \ - ./packages/playground/cli/src/cli.ts server \ - --port=$PORT \ - --mount="$HOST_PATH:/wordpress/$VERSION" \ - --quiet& \ - ) + cd $HOST_PATH + python3 -c " + import http.server + import socketserver + from urllib.parse import unquote + + class PrefixHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path.startswith('/${{ env.VERSION }}/'): + self.path = self.path[len('/${{ env.VERSION }}'):] + elif self.path == '/${{ env.VERSION }}': + self.path = '/' + super().do_GET() + + with socketserver.TCPServer(('127.0.0.1', ${{ env.PORT }}), PrefixHTTPRequestHandler) as httpd: + httpd.serve_forever() + " & - name: Wait for the package server to be ready run: | for i in {1..60}; do @@ -238,7 +241,8 @@ jobs: done - name: Run integration tests in an ES Modules project run: | - cd packages/playground/test-built-npm-packages/es-modules-and-vitest + cp -r packages/playground/test-built-npm-packages/es-modules-and-vitest /tmp/es-modules-test + cd /tmp/es-modules-test jq --arg package_url "$PACKAGE_URL" '.devDependencies["@wp-playground/cli"] = $package_url' package.json > package.json.tmp mv package.json.tmp package.json npm install @@ -252,7 +256,8 @@ jobs: npm run test - name: Run integration tests in a CommonJS project run: | - cd packages/playground/test-built-npm-packages/commonjs-and-jest + cp -r packages/playground/test-built-npm-packages/commonjs-and-jest /tmp/commonjs-test + cd /tmp/commonjs-test jq --arg package_url "$PACKAGE_URL" '.devDependencies["@wp-playground/cli"] = $package_url' package.json > package.json.tmp mv package.json.tmp package.json npm install diff --git a/packages/php-wasm/compile/php/Dockerfile b/packages/php-wasm/compile/php/Dockerfile index 1e90b61f7b..1caea3ab90 100644 --- a/packages/php-wasm/compile/php/Dockerfile +++ b/packages/php-wasm/compile/php/Dockerfile @@ -642,6 +642,7 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ export ASYNCIFY_ONLY_UNPREFIXED=$'"__fseeko_unlocked",\ "__ftello_unlocked",\ "__funcs_on_exit",\ +"__funcs_on_exit",\ "__fwritex",\ "__netlink_enumerate",\ "__overflow",\ @@ -812,159 +813,182 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ "dynCall_viiiiii",\ "dynCall_viiiiiii",\ "dynCall_viiiiiiii",\ -"dynCall_viiji",\ -"dynCall_viijii",\ -"dynCall_vij",\ -"dynCall_vijii",\ -"dynCall_vji",\ -"dynCall_vjiii",\ -"easy_perform",\ -"easy_transfer",\ -"RAND_poll",\ -"rand_bytes",\ -"RAND_bytes",\ -"SSL_CTX_new",\ -"rand_nopseudo_bytes",\ -"rand_enough",\ -"Curl_ossl_seed",\ -"zif_curl_multi_exec",\ -"zif_curl_multi_select",\ -"ossl_connect_common",\ -"Curl_ossl_connect_nonblocking",\ -"Curl_ssl_connect_nonblocking",\ -"https_connecting",\ -"Curl_readwrite",\ -"zend_fetch_debug_backtrace",\ -"zend_default_exception_new",\ -"zend_throw_exception_zstr",\ -"zend_throw_exception",\ -"zend_type_error",\ -"zend_generator_iterator_move_forward",\ -"ZEND_FE_FETCH_R_SPEC_VAR_HANDLER",\ -"zend_generator_resume",\ -"zend_generator_iterator_rewind",\ -"zend_fe_reset_iterator",\ -"ZEND_FE_RESET_R_SPEC_VAR_HANDLER",\ -"zend_user_it_get_new_iterator",\ -"ZEND_ADD_ARRAY_UNPACK_SPEC_HANDLER",\ -"ZEND_COUNT_SPEC_CV_UNUSED_HANDLER",\ "__fwritex",\ -"read",\ -"zif_sleep",\ -"zif_stream_get_contents",\ -"zif_stream_select",\ -"_php_stream_fill_read_buffer",\ -"_php_stream_read",\ -"php_stream_read_to_str",\ -"php_userstreamop_read",\ -"zif_fread",\ -"wasm_read",\ -"php_stdiop_read",\ -"fwrite",\ -"zif_fwrite",\ -"php_stdiop_write",\ -"zif_array_filter",\ -"zend_call_known_instance_method_with_2_params",\ -"zend_fetch_dimension_address_read_R",\ -"_zval_dtor_func_for_ptr",\ -"ZEND_ASSIGN_DIM_SPEC_CV_CONST_HANDLER",\ -"zend_fetch_dimension_address_read",\ -"php_if_fopen",\ -"_php_stream_cast",\ -"php_stream_temp_cast",\ -"php_stream_temp_flush",\ -"_php_stream_flush",\ -"php_stream_flush",\ -"_php_stream_write",\ -"php_stream_write",\ -"_php_stream_free",\ -"_php_stream_free_enclosed",\ -"stream_resource_persistent_dtor",\ -"stream_resource_regular_dtor",\ -"zend_std_has_dimension",\ -"zend_isset_isempty_dim_prop_obj_handler_SPEC_CV_CONST",\ -"zend_assign_to_object",\ -"zif_file_put_contents",\ -"ZEND_ASSIGN_OBJ_SPEC_CV_CONST_HANDLER",\ -"ZEND_FETCH_OBJ_R_SPEC_CV_CV_HANDLER",\ -"zend_std_call_user_call",\ -"zend_objects_store_del_ref_by_handle_ex",\ -"zend_objects_store_del_ref",\ -"_zval_dtor_func",\ -"_zval_ptr_dtor",\ -"zend_hash_del_key_or_index",\ -"zend_delete_variable",\ -"ZEND_UNSET_VAR_SPEC_CV_UNUSED_HANDLER",\ -"zend_std_unset_dimension",\ -"ZEND_UNSET_DIM_SPEC_CV_CONST_HANDLER",\ -"zend_std_read_dimension",\ -"zend_fetch_dimension_address_read_R_slow",\ -"ZEND_FETCH_DIM_R_SPEC_CV_CONST_HANDLER",\ -"zend_call_method",\ -"zend_assign_to_object_dim",\ -"ZEND_ASSIGN_DIM_SPEC_CV_CONST_OP_DATA_CONST_HANDLER",\ -"zend_std_write_dimension",\ -"zend_isset_dim_slow",\ -"ZEND_ISSET_ISEMPTY_DIM_OBJ_SPEC_CV_CONST_HANDLER",\ -"zend_std_write_property",\ -"ZEND_ASSIGN_OBJ_SPEC_CV_CONST_OP_DATA_CONST_HANDLER",\ -"zend_objects_store_del",\ -"ZEND_UNSET_CV_SPEC_CV_UNUSED_HANDLER",\ -"ZEND_DO_FCALL_BY_NAME_SPEC_HANDLER",\ -"ZEND_DO_FCALL_BY_NAME_SPEC_OBSERVER_HANDLER",\ -"ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_HANDLER",\ -"ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_USED_HANDLER",\ -"ZEND_DO_FCALL_SPEC_CONST_HANDLER",\ -"ZEND_DO_FCALL_SPEC_HANDLER",\ -"ZEND_DO_FCALL_SPEC_OBSERVER_HANDLER",\ -"ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER",\ -"ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER",\ -"ZEND_DO_ICALL_SPEC_HANDLER",\ -"ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER",\ -"ZEND_DO_ICALL_SPEC_RETVAL_USED_HANDLER",\ -"ZEND_DO_UCALL_SPEC_OBSERVER_HANDLER",\ -"ZEND_FETCH_OBJ_FUNC_ARG_SPEC_CV_CONST_HANDLER",\ -"ZEND_FETCH_OBJ_R_SPEC_CV_CONST_HANDLER",\ -"ZEND_FETCH_OBJ_R_SPEC_TMPVAR_CONST_HANDLER",\ -"ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_CV_CONST_HANDLER",\ -"ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_CV_HANDLER",\ -"ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_CV_TMPVAR_HANDLER",\ -"ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_TMPVAR_CONST_HANDLER",\ -"ZEND_ISSET_ISEMPTY_PROP_OBJ_SPEC_TMPVAR_HANDLER",\ -"ZEND_ASSIGN_OBJ_SPEC_VAR_CV_OP_DATA_CV_HANDLER",\ -"ZEND_ASSIGN_OBJ_SPEC_VAR_CONST_OP_DATA_CV_HANDLER",\ -"ZEND_ASSIGN_OBJ_SPEC_VAR_CONST_OP_DATA_VAR_HANDLER",\ -"ZEND_ASSIGN_OBJ_SPEC_VAR_CONST_OP_DATA_TMP_HANDLER",\ -"ZEND_ASSIGN_OBJ_SPEC_VAR_CONST_OP_DATA_CONST_HANDLER",\ -"ZEND_ASSIGN_SPEC_CV_VAR_RETVAL_UNUSED_HANDLER",\ -"cli",\ -"wasm_sleep",\ -"wasm_php_stream_flush",\ -"wasm_php_stream_read",\ -"php_crc32_stream_bulk_update",\ -"phar_parse_zipfile",\ -"get_http_body",\ -"wasm_php_exec",\ -"wasm_sapi_handle_request",\ -"wasm_sapi_request_shutdown",\ +"__netlink_enumerate",\ +"__overflow",\ +"__toread",\ +"__uflow",\ +"__vfprintf_internal",\ +"__wrap_select",\ +"__zend_malloc",\ "_call_user_function_ex",\ "_call_user_function_impl",\ +"_emalloc_large",\ +"_erealloc",\ +"_erealloc2",\ +"_estrdup",\ +"_get_zval_cv_lookup",\ +"_is_numeric_string_ex",\ +"_mysqlnd_init_ps_subsystem",\ "_mysqlnd_run_command",\ +"_pcre2_memctl_malloc_8",\ +"_pdo_mysql_error",\ +"_php_error_log_ex",\ +"_php_import_environment_variables",\ +"_php_stream_cast",\ "_php_stream_copy_to_mem",\ "_php_stream_copy_to_stream_ex",\ "_php_stream_eof",\ "_php_stream_fill_read_buffer",\ +"_php_stream_filter_flush",\ +"_php_stream_flush",\ +"_php_stream_free_enclosed",\ "_php_stream_free",\ "_php_stream_get_line",\ +"_php_stream_mkdir",\ "_php_stream_open_wrapper_ex",\ +"_php_stream_opendir",\ +"_php_stream_passthru",\ "_php_stream_read",\ +"_php_stream_rmdir",\ +"_php_stream_seek",\ "_php_stream_set_option",\ +"_php_stream_stat_path",\ +"_php_stream_stat",\ +"_php_stream_sync",\ +"_php_stream_truncate_set_size",\ +"_php_stream_write_buffer",\ +"_php_stream_write_filtered",\ "_php_stream_write",\ "_php_stream_xport_create",\ -"zif_stream_socket_enable_crypto",\ -"do_cli",\ +"_safe_emalloc",\ +"_try_convert_to_string",\ +"_zend_hash_init",\ +"_zend_new_array_0",\ +"_zend_new_array",\ +"_zend_observe_fcall_begin",\ +"_zend_observer_class_linked_notify",\ +"_zend_observer_error_notify",\ +"_zend_observer_function_declared_notify",\ +"_zval_dtor_func_for_ptr",\ +"_zval_dtor_func",\ +"_zval_ptr_dtor",\ +"autoVacuumCommit",\ +"bsearch",\ +"btreeCellSizeCheck",\ +"btreeEndTransaction",\ +"btreeParseCell",\ +"call_extract_if_dead",\ +"call",\ +"callCollNeeded",\ +"callFinaliser",\ +"checkTreePage",\ +"cleanup_unfinished_calls",\ +"clearDatabasePage",\ +"cli_register_file_handles",\ +"cli",\ +"close_stmt_and_copy_errors",\ +"close",\ +"closeUnixFile",\ +"compile_file",\ +"createCollation",\ +"createFunctionApi",\ +"createModule",\ +"ctype_fallback",\ +"Curl_conncache_foreach",\ +"curl_easy_perform",\ +"Curl_http_connect",\ +"Curl_is_connected",\ +"curl_multi_perform",\ +"curl_multi_poll",\ +"Curl_multi_wait",\ +"Curl_ossl_connect_nonblocking",\ +"Curl_ossl_seed",\ +"Curl_poll",\ +"Curl_proxy_connect",\ +"Curl_readwrite",\ +"Curl_socket_check",\ +"Curl_ssl_connect_nonblocking",\ +"Curl_wait_ms",\ +"dbh_free",\ +"dbh_get_gc",\ +"decrement_function",\ +"deflate",\ +"deflateEnd",\ +"deflateInit2_",\ +"defragmentPage",\ +"destroy_op_array",\ "do_cli_server",\ -"execute_ex",\ +"do_cli",\ +"do_fetch_common",\ +"do_fetch",\ +"dotlockCheckReservedLock",\ +"dotlockLock",\ +"dotlockUnlock",\ +"doWalCallbacks",\ +"dynCall_dd",\ +"dynCall_ddd",\ +"dynCall_di",\ +"dynCall_dii",\ +"dynCall_i",\ +"dynCall_ii",\ +"dynCall_iidiiii",\ +"dynCall_iii",\ +"dynCall_iiid",\ +"dynCall_iiii",\ +"dynCall_iiiii",\ +"dynCall_iiiiii",\ +"dynCall_iiiiiii",\ +"dynCall_iiiiiiii",\ +"dynCall_iiiiiiiii",\ +"dynCall_iiiiiiiiii",\ +"dynCall_iiiiiiiiiii",\ +"dynCall_iiiiiiiiiiii",\ +"dynCall_iiiiiiiiiiij",\ +"dynCall_iiiiiij",\ +"dynCall_iiiiijii",\ +"dynCall_iiiij",\ +"dynCall_iiiiji",\ +"dynCall_iiiijii",\ +"dynCall_iiiijji",\ +"dynCall_iiij",\ +"dynCall_iiiji",\ +"dynCall_iij",\ +"dynCall_iiji",\ +"dynCall_iijii",\ +"dynCall_iijiji",\ +"dynCall_iijj",\ +"dynCall_ij",\ +"dynCall_j",\ +"dynCall_ji",\ +"dynCall_jii",\ +"dynCall_jiii",\ +"dynCall_jiiii",\ +"dynCall_jiiiji",\ +"dynCall_jiij",\ +"dynCall_jiiji",\ +"dynCall_jiji",\ +"dynCall_jijj",\ +"dynCall_jj",\ +"dynCall_v",\ +"dynCall_vi",\ +"dynCall_vid",\ +"dynCall_vii",\ +"dynCall_viidii",\ +"dynCall_viii",\ +"dynCall_viiii",\ +"dynCall_viiiii",\ +"dynCall_viiiiii",\ +"dynCall_viiiiiii",\ +"dynCall_viiiiiiii",\ +"dynCall_viiji",\ +"dynCall_viijii",\ +"dynCall_vij",\ +"dynCall_vijii",\ +"dynCall_vji",\ +"dynCall_vjiii",\ +"easy_perform",\ +"easy_transfer",\ "EVP_PKEY_encrypt",\ "execute_ex",\ "exif_error_docref",\ @@ -979,6 +1003,8 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ "find_implicit_binds_recursively",\ "findInodeInfo",\ "findReusableFd",\ +"fwrite",\ +"fopen",\ "flock",\ "fread",\ "functionDestroy",\ @@ -1013,29 +1039,24 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ "mysqli_commit_or_rollback_libmysql",\ "mysqli_common_connect",\ "mysqlnd_caching_sha2_handle_server_response",\ -"mysqlnd_com_handshake_run",\ -"mysqlnd_com_init_db_run",\ -"mysqlnd_com_stmt_execute_run",\ -"mysqlnd_com_stmt_prepare_run",\ -"mysqlnd_com_set_option_run",\ -"mysqlnd_com_ping_run",\ -"mysqlnd_com_set_option_run",\ +"mysqlnd_com_change_user_run",\ "mysqlnd_com_debug_run",\ +"mysqlnd_com_enable_ssl_run",\ +"mysqlnd_com_handshake_run",\ "mysqlnd_com_init_db_run",\ "mysqlnd_com_ping_run",\ -"mysqlnd_com_statistics_run",\ "mysqlnd_com_process_kill_run",\ -"mysqlnd_com_refresh_run",\ -"mysqlnd_com_quit_run",\ "mysqlnd_com_query_run",\ -"mysqlnd_com_change_user_run",\ +"mysqlnd_com_quit_run",\ "mysqlnd_com_reap_result_run",\ +"mysqlnd_com_refresh_run",\ +"mysqlnd_com_set_option_run",\ +"mysqlnd_com_statistics_run",\ +"mysqlnd_com_stmt_close_run",\ +"mysqlnd_com_stmt_execute_run",\ "mysqlnd_com_stmt_prepare_run",\ "mysqlnd_com_stmt_reset_run",\ "mysqlnd_com_stmt_send_long_data_run",\ -"mysqlnd_com_stmt_close_run",\ -"mysqlnd_com_enable_ssl_run",\ -"mysqlnd_com_handshake_run",\ "mysqlnd_connect",\ "mysqlnd_connection_connect",\ "mysqlnd_mysqlnd_command_handshake_pub",\ @@ -1199,6 +1220,7 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ "php_openssl_sockop_write",\ "php_pcre_replace_func_impl",\ "php_pdo_free_statement",\ +"php_pdo_internal_construct_driver",\ "php_pollfd_for",\ "php_replace_in_subject_func",\ "php_request_shutdown",\ @@ -1568,6 +1590,7 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ "ZEND_ASSIGN_OBJ_SPEC_VAR_CONST_OP_DATA_TMP_HANDLER",\ "ZEND_ASSIGN_OBJ_SPEC_VAR_CONST_OP_DATA_VAR_HANDLER",\ "ZEND_ASSIGN_OBJ_SPEC_VAR_CV_OP_DATA_CV_HANDLER",\ +"ZEND_ASSIGN_SPEC_CV_VAR_RETVAL_UNUSED_HANDLER",\ "zend_assign_to_object_dim",\ "zend_assign_to_object",\ "zend_ast_apply",\ @@ -2003,7 +2026,7 @@ RUN export ASYNCIFY_IMPORTS=$'["_dlopen_js",\n\ "zval_ptr_dtor",\ "zval_try_get_string_func",\ "zval_undefined_cv",\ -"zval_update_constant_ex"';\ +"zval_update_constant_ex"'; \ # If pointer casts are enabled, we need to asyncify both the prefixed and unprefixed names if [ "${PHP_VERSION:0:1}" -lt "8" ]; then \ export ASYNCIFY_ONLY="$ASYNCIFY_ONLY_UNPREFIXED,"$(echo "$ASYNCIFY_ONLY_UNPREFIXED" | sed -E $'s/"([a-zA-Z])/"byn$fpcast-emu$\\1/g'); \ @@ -2091,6 +2114,7 @@ RUN set -euxo pipefail; \ -s ERROR_ON_UNDEFINED_SYMBOLS=1 \ -s NODEJS_CATCH_EXIT=0 \ -s NODEJS_CATCH_REJECTION=0 \ + -s ASSERTIONS=0 \ -s INVOKE_RUN=0 \ -o /build/output/php.js \ -s EXIT_RUNTIME=1 \ diff --git a/packages/php-wasm/compile/php/phpwasm-emscripten-library.js b/packages/php-wasm/compile/php/phpwasm-emscripten-library.js index 0e92ab283f..2d47769840 100644 --- a/packages/php-wasm/compile/php/phpwasm-emscripten-library.js +++ b/packages/php-wasm/compile/php/phpwasm-emscripten-library.js @@ -22,19 +22,27 @@ const LibraryExample = { .filter(Boolean) .join(':'); + + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/logger/src/lib/logger.ts b/packages/php-wasm/logger/src/lib/logger.ts index a572262fdb..22e9ef415c 100644 --- a/packages/php-wasm/logger/src/lib/logger.ts +++ b/packages/php-wasm/logger/src/lib/logger.ts @@ -152,6 +152,9 @@ export class Logger extends EventTarget { * @param args any */ public error(message: any, ...args: any[]): void { + // @TODO: Actually log all the information carried by the error object. + // Right now this only captures the message and stack of the top-level error. + // It ignores the chain of causes and every other property of the error object. this.logMessage( { message, diff --git a/packages/php-wasm/node/asyncify/php_7_2.js b/packages/php-wasm/node/asyncify/php_7_2.js index 821e8c0870..7292f4fed9 100644 --- a/packages/php-wasm/node/asyncify/php_7_2.js +++ b/packages/php-wasm/node/asyncify/php_7_2.js @@ -7011,19 +7011,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_7_3.js b/packages/php-wasm/node/asyncify/php_7_3.js index 1bb3e5499e..0acfce7d99 100644 --- a/packages/php-wasm/node/asyncify/php_7_3.js +++ b/packages/php-wasm/node/asyncify/php_7_3.js @@ -7011,19 +7011,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_7_4.js b/packages/php-wasm/node/asyncify/php_7_4.js index 5eab4a7630..eb364ef4f1 100644 --- a/packages/php-wasm/node/asyncify/php_7_4.js +++ b/packages/php-wasm/node/asyncify/php_7_4.js @@ -7011,19 +7011,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_8_0.js b/packages/php-wasm/node/asyncify/php_8_0.js index 3cd55dfb1f..2ee565436e 100644 --- a/packages/php-wasm/node/asyncify/php_8_0.js +++ b/packages/php-wasm/node/asyncify/php_8_0.js @@ -7011,19 +7011,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_8_1.js b/packages/php-wasm/node/asyncify/php_8_1.js index 3322acdb49..7fc7929846 100644 --- a/packages/php-wasm/node/asyncify/php_8_1.js +++ b/packages/php-wasm/node/asyncify/php_8_1.js @@ -7039,19 +7039,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_8_2.js b/packages/php-wasm/node/asyncify/php_8_2.js index 11d089ae8d..8ac9d81866 100644 --- a/packages/php-wasm/node/asyncify/php_8_2.js +++ b/packages/php-wasm/node/asyncify/php_8_2.js @@ -7041,19 +7041,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_8_3.js b/packages/php-wasm/node/asyncify/php_8_3.js index 735ee59124..cf96c9acf3 100644 --- a/packages/php-wasm/node/asyncify/php_8_3.js +++ b/packages/php-wasm/node/asyncify/php_8_3.js @@ -7041,19 +7041,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/asyncify/php_8_4.js b/packages/php-wasm/node/asyncify/php_8_4.js index f92ccbf394..1fd441b57e 100644 --- a/packages/php-wasm/node/asyncify/php_8_4.js +++ b/packages/php-wasm/node/asyncify/php_8_4.js @@ -7041,19 +7041,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_7_2.js b/packages/php-wasm/node/jspi/php_7_2.js index a932037328..8f70a72e3e 100644 --- a/packages/php-wasm/node/jspi/php_7_2.js +++ b/packages/php-wasm/node/jspi/php_7_2.js @@ -5236,19 +5236,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_7_3.js b/packages/php-wasm/node/jspi/php_7_3.js index a9589884e4..3c15bdf6b4 100644 --- a/packages/php-wasm/node/jspi/php_7_3.js +++ b/packages/php-wasm/node/jspi/php_7_3.js @@ -5236,19 +5236,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_7_4.js b/packages/php-wasm/node/jspi/php_7_4.js index 5f3054ed63..510da9dc89 100644 --- a/packages/php-wasm/node/jspi/php_7_4.js +++ b/packages/php-wasm/node/jspi/php_7_4.js @@ -5236,19 +5236,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_8_0.js b/packages/php-wasm/node/jspi/php_8_0.js index 2090f2b148..8d6963d60f 100644 --- a/packages/php-wasm/node/jspi/php_8_0.js +++ b/packages/php-wasm/node/jspi/php_8_0.js @@ -5254,19 +5254,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_8_1.js b/packages/php-wasm/node/jspi/php_8_1.js index 17bc427972..2fd4a8f852 100644 --- a/packages/php-wasm/node/jspi/php_8_1.js +++ b/packages/php-wasm/node/jspi/php_8_1.js @@ -5254,19 +5254,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_8_2.js b/packages/php-wasm/node/jspi/php_8_2.js index 464b4d25b7..c8ab762448 100644 --- a/packages/php-wasm/node/jspi/php_8_2.js +++ b/packages/php-wasm/node/jspi/php_8_2.js @@ -5254,19 +5254,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_8_3.js b/packages/php-wasm/node/jspi/php_8_3.js index 31f4f1f6ee..c927ddce24 100644 --- a/packages/php-wasm/node/jspi/php_8_3.js +++ b/packages/php-wasm/node/jspi/php_8_3.js @@ -5254,19 +5254,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/jspi/php_8_4.js b/packages/php-wasm/node/jspi/php_8_4.js index d0c72492d3..cab53261dd 100644 --- a/packages/php-wasm/node/jspi/php_8_4.js +++ b/packages/php-wasm/node/jspi/php_8_4.js @@ -5254,19 +5254,26 @@ export function init(RuntimeName, PHPLoader) { .filter(Boolean) .join(':'); + function mkdirIfMissing(path) { + try { + FS.mkdir(path); + } catch (e) { + if (e.errno != 20) throw e; + } + } // The /internal directory is required by the C module. It's where the // stdout, stderr, and headers information are written for the JavaScript // code to read later on. - FS.mkdir('/internal'); + mkdirIfMissing('/internal'); // The files from the shared directory are shared between all the // PHP processes managed by PHPProcessManager. - FS.mkdir('/internal/shared'); + mkdirIfMissing('/internal/shared'); // The files from the preload directory are preloaded using the // auto_prepend_file php.ini directive. - FS.mkdir('/internal/shared/preload'); + mkdirIfMissing('/internal/shared/preload'); // Platform-level bin directory for a fallback `php` binary. Without it, // PHP may not populate the PHP_BINARY constant. - FS.mkdir('/internal/shared/bin'); + mkdirIfMissing('/internal/shared/bin'); const originalOnRuntimeInitialized = Module['onRuntimeInitialized']; Module['onRuntimeInitialized'] = () => { // Dummy PHP binary for PHP to populate the PHP_BINARY constant. diff --git a/packages/php-wasm/node/src/lib/data/with-icu-data.ts b/packages/php-wasm/node/src/lib/data/with-icu-data.ts index 2e4fae97ae..0e62129971 100644 --- a/packages/php-wasm/node/src/lib/data/with-icu-data.ts +++ b/packages/php-wasm/node/src/lib/data/with-icu-data.ts @@ -31,7 +31,6 @@ export async function withICUData( `${phpRuntime.ENV.ICU_DATA}/${fileName}` ) ) { - phpRuntime.FS.mkdirTree(phpRuntime.ENV.ICU_DATA); phpRuntime.FS.writeFile( `${phpRuntime.ENV.ICU_DATA}/${fileName}`, new Uint8Array(ICUData) diff --git a/packages/php-wasm/node/src/lib/node-fs-mount.ts b/packages/php-wasm/node/src/lib/node-fs-mount.ts index 7f7ab08106..e75adeac0a 100644 --- a/packages/php-wasm/node/src/lib/node-fs-mount.ts +++ b/packages/php-wasm/node/src/lib/node-fs-mount.ts @@ -1,7 +1,7 @@ import type { MountHandler } from '@php-wasm/universal'; export function createNodeFsMountHandler(localPath: string): MountHandler { - return async function (php, FS, vfsMountPoint) { + return function (php, FS, vfsMountPoint) { FS.mount(FS.filesystems['NODEFS'], { root: localPath }, vfsMountPoint); return () => { FS!.unmount(vfsMountPoint); diff --git a/packages/php-wasm/node/src/test/php-process-manager.spec.ts b/packages/php-wasm/node/src/test/php-process-manager.spec.ts index 64f6d995a7..9b1b6917f5 100644 --- a/packages/php-wasm/node/src/test/php-process-manager.spec.ts +++ b/packages/php-wasm/node/src/test/php-process-manager.spec.ts @@ -50,11 +50,11 @@ describe('PHPProcessManager', () => { timeout: 100, }); - await mgr.acquirePHPInstance(); - await mgr.acquirePHPInstance(); - await expect(() => mgr.acquirePHPInstance()).rejects.toThrowError( - /Requested more concurrent PHP instances/ - ); + await mgr.acquirePHPInstance({ considerPrimary: true }); + await mgr.acquirePHPInstance({ considerPrimary: true }); + await expect(() => + mgr.acquirePHPInstance({ considerPrimary: true }) + ).rejects.toThrowError(/Requested more concurrent PHP instances/); }); it('should refuse to spawn more PHP instances than the maximum (limit=3)', async () => { @@ -65,12 +65,12 @@ describe('PHPProcessManager', () => { timeout: 100, }); - await mgr.acquirePHPInstance(); - await mgr.acquirePHPInstance(); - await mgr.acquirePHPInstance(); - await expect(() => mgr.acquirePHPInstance()).rejects.toThrowError( - /Requested more concurrent PHP instances/ - ); + await mgr.acquirePHPInstance({ considerPrimary: true }); + await mgr.acquirePHPInstance({ considerPrimary: true }); + await mgr.acquirePHPInstance({ considerPrimary: true }); + await expect(() => + mgr.acquirePHPInstance({ considerPrimary: true }) + ).rejects.toThrowError(/Requested more concurrent PHP instances/); }); it('should not start a second PHP instance until the first getInstance() call when the primary instance is busy', async () => { @@ -83,16 +83,16 @@ describe('PHPProcessManager', () => { }); expect(phpFactory).not.toHaveBeenCalled(); - const php1 = await mgr.acquirePHPInstance(); + const php1 = await mgr.acquirePHPInstance({ considerPrimary: true }); expect(phpFactory).toHaveBeenCalledTimes(1); php1.reap(); - const php2 = await mgr.acquirePHPInstance(); + const php2 = await mgr.acquirePHPInstance({ considerPrimary: true }); expect(phpFactory).toHaveBeenCalledTimes(1); php2.reap(); - await mgr.acquirePHPInstance(); - await mgr.acquirePHPInstance(); + await mgr.acquirePHPInstance({ considerPrimary: true }); + await mgr.acquirePHPInstance({ considerPrimary: true }); expect(phpFactory).toHaveBeenCalledTimes(3); }); diff --git a/packages/php-wasm/universal/src/lib/api.ts b/packages/php-wasm/universal/src/lib/api.ts index 599315a48e..f687063fe9 100644 --- a/packages/php-wasm/universal/src/lib/api.ts +++ b/packages/php-wasm/universal/src/lib/api.ts @@ -4,6 +4,7 @@ import type { Endpoint } from 'comlink'; import * as Comlink from 'comlink'; import type { NodeEndpoint } from 'comlink/dist/esm/node-adapter'; import nodeEndpoint from 'comlink/dist/esm/node-adapter'; +import * as ErrorSerializer from './serialize-error'; export type WithAPIState = { /** @@ -179,22 +180,56 @@ function setupTransferHandlers() { return PHPResponse.fromRawData(responseData); }, }); - // Augment Comlink's throw handler to include Error the response and source - // information in the serialized error object. BasePHP may throw - // PHPExecutionFailureError which includes those information and we'll want to - // display them for the user. - const throwHandler = Comlink.transferHandlers.get('throw')!; - const originalSerialize = throwHandler?.serialize; - throwHandler.serialize = ({ value }: any) => { - const serialized = originalSerialize({ value }) as any; - if (value.response) { - serialized[0].value.response = value.response; - } - if (value.source) { - serialized[0].value.source = value.source; - } - return serialized; + // Augment Comlink's throw handler to include all the information carried by + // the thrown object, including the cause, additional properties, etc. + interface UnserializedError { + value: unknown; + } + type SerializedError = + | { isError: true; value: ErrorSerializer.ErrorObject } + | { isError: false; value: unknown }; + + const throwTransferHandler = Comlink.transferHandlers.get( + 'throw' + ) as Comlink.TransferHandler; + + const throwTransferHandlerCustom: Comlink.TransferHandler< + UnserializedError, + SerializedError + > = { + canHandle: throwTransferHandler.canHandle, + serialize: ({ value }) => { + let serialized: SerializedError; + if (value instanceof Error) { + serialized = { + isError: true, + value: ErrorSerializer.serializeError(value), + }; + // The error class name is not serialized by serialize-error, let's add it manually. + serialized.value['originalErrorClassName'] = + value.constructor.name; + } else { + serialized = { isError: false, value }; + } + return [serialized, []]; + }, + deserialize: (serialized) => { + if (serialized.isError) { + const error = ErrorSerializer.deserializeError( + serialized.value + ); + /** + * Rethrow to capture the stack trace of the original Comlink call. + */ + throw new Error('Comlink method call failed', { + cause: error, + }); + } + throw serialized.value; + }, }; + + Comlink.transferHandlers.set('throw', throwTransferHandlerCustom); } function proxyClone(object: any): any { diff --git a/packages/php-wasm/universal/src/lib/error-reporting.ts b/packages/php-wasm/universal/src/lib/error-reporting.ts new file mode 100644 index 0000000000..26817965dc --- /dev/null +++ b/packages/php-wasm/universal/src/lib/error-reporting.ts @@ -0,0 +1,89 @@ +import type { StreamedPHPResponse } from './php-response'; +import { PHPResponse } from './php-response'; + +export async function printDebugDetails( + e: any, + streamedResponse?: StreamedPHPResponse +) { + if (streamedResponse) { + printResponseDebugDetails( + await PHPResponse.fromStreamedResponse(streamedResponse) + ); + } + await prettyPrintFullStackTrace(e); +} + +/** + * Pretty prints the full stack trace of the error and all its causes. + * Includes debug details for each error in the chain. + * This is needed + * + * @param e + */ +export async function prettyPrintFullStackTrace(e: any) { + let current = e; + let isFirst = true; + while (current) { + if (!isFirst) { + process.stderr.write('\nCaused by:\n\n'); + } + + process.stderr.write(current.originalErrorClassName ?? current.name); + process.stderr.write(': ' + current.message + '\n'); + process.stderr.write( + (current.stack + '').split('\n').slice(1).join('\n') + ); + process.stderr.write(`\n`); + if (current.response) { + printResponseDebugDetails(current.response); + } + if (current.phpLogs) { + process.stderr.write(`\n\n==== PHP error log ====\n\n`); + process.stderr.write(current.phpLogs); + } + current = current.cause; + isFirst = false; + } + process.stderr.write('\n'); +} + +export function printResponseDebugDetails(response: PHPResponse) { + // Print a short summary of what we have: + process.stderr.write( + `\n exitCode=${response.exitCode} httpStatusCode=${response.httpStatusCode} ` + ); + const hasHeaders = + response.headers && Object.keys(response.headers).length > 0; + if (!hasHeaders) { + process.stderr.write(`responseHeaders=(empty) `); + } + if (!response.text) { + process.stderr.write(`stdout=(empty) `); + } + if (!response.errors) { + process.stderr.write(`stderr=(empty) `); + } + process.stderr.write(`\n`); + + // Print all the extended information in a separate section: + if (hasHeaders) { + process.stderr.write( + `\n==== PHP response headers ====\n\n${JSON.stringify( + response.headers, + null, + 2 + )}\n\n` + ); + } + + if (response.text) { + process.stderr.write(`\n==== PHP stdout ====\n\n`); + process.stderr.write(response.text); + } + + if (response.errors) { + process.stderr.write(`\n==== PHP stderr ====\n\n`); + process.stderr.write(response.errors); + } + process.stderr.write(`\n`); +} diff --git a/packages/php-wasm/universal/src/lib/fs-helpers.ts b/packages/php-wasm/universal/src/lib/fs-helpers.ts index d113b8f5eb..a6a771e574 100644 --- a/packages/php-wasm/universal/src/lib/fs-helpers.ts +++ b/packages/php-wasm/universal/src/lib/fs-helpers.ts @@ -280,7 +280,19 @@ export class FSHelpers { * @param path - The directory path to create. */ static mkdir(FS: Emscripten.RootFS, path: string) { - FS.mkdirTree(path); + try { + FS.mkdirTree(path); + } catch (e) { + // errno 20 means the directory already exists – we can ignore those errors. + if ( + e !== null && + typeof e === 'object' && + 'errno' in e && + e.errno !== 20 + ) { + throw e; + } + } } static copyRecursive( diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index 8fb02068b9..eb0a32d34b 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -10,6 +10,11 @@ export type { PHPRequestHeaders, SpawnHandler, } from './universal-php'; +export { + printDebugDetails, + prettyPrintFullStackTrace, + printResponseDebugDetails, +} from './error-reporting'; export { FSHelpers } from './fs-helpers'; export type { ListFilesOptions, RmDirOptions } from './fs-helpers'; export { PHPWorker } from './php-worker'; @@ -76,6 +81,7 @@ export { export { isExitCode } from './is-exit-code'; export { proxyFileSystem } from './proxy-file-system'; +export { sandboxedSpawnHandlerFactory } from './sandboxed-spawn-handler-factory'; export * from './api'; export type { WithAPIState as WithIsReady } from './api'; diff --git a/packages/php-wasm/universal/src/lib/load-php-runtime.ts b/packages/php-wasm/universal/src/lib/load-php-runtime.ts index e3302356f5..ad5a097bcf 100644 --- a/packages/php-wasm/universal/src/lib/load-php-runtime.ts +++ b/packages/php-wasm/universal/src/lib/load-php-runtime.ts @@ -140,10 +140,10 @@ export async function loadPHPRuntime( // let's just log it. logger.error(reason); }, - ENV: {}, // Emscripten sometimes prepends a '/' to the path, which // breaks vite dev mode. An identity `locateFile` function // fixes it. + ENV: {}, locateFile: (path) => path, ...phpModuleArgs, noInitialRun: true, @@ -159,9 +159,6 @@ export async function loadPHPRuntime( const id = ++lastRuntimeId; - // TODO: Ask @adamziel why this is here. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- why is this here? - PHPRuntime.FS; PHPRuntime.id = id; PHPRuntime.originalExit = PHPRuntime._exit; diff --git a/packages/php-wasm/universal/src/lib/php-process-manager.ts b/packages/php-wasm/universal/src/lib/php-process-manager.ts index 9810f8a633..dc4efa98f5 100644 --- a/packages/php-wasm/universal/src/lib/php-process-manager.ts +++ b/packages/php-wasm/universal/src/lib/php-process-manager.ts @@ -144,16 +144,17 @@ export class PHPProcessManager implements AsyncDisposable { * and the waiting timeout is exceeded. */ async acquirePHPInstance({ - considerPrimary = true, + considerPrimary = false, }: { considerPrimary?: boolean; } = {}): Promise { /** * First and foremost, make sure we have the primary PHP instance in place. - * We don't acquire it yet. We just make sure it exists. + * We may not actually acquire it. We just need it to exist. * - * @TODO: Decouple Filesystem from PHP to get rid of the notion of a primary PHP instance. - * @see https://github.com/WordPress/wordpress-playground/issues/2269 + * @TODO: Re-evaluate why we need it to exist. Should spawn() be just more + * lenient with its "another primary instance already started spawning" + * check? */ if (!this.primaryPhp) { await this.getPrimaryPhp(); @@ -200,12 +201,10 @@ export class PHPProcessManager implements AsyncDisposable { * for PHP to spawn. */ private spawn(factoryArgs: PHPFactoryOptions): Promise { - if (factoryArgs.isPrimary) { - if (this.primaryPhpPromise && !this.primaryPhp) { - throw new Error( - 'Requested spawning a primary PHP instance when another primary instance already started spawning.' - ); - } + if (factoryArgs.isPrimary && this.allInstances.length > 0) { + throw new Error( + 'Requested spawning a primary PHP instance when another primary instance already started spawning.' + ); } const spawned = this.doSpawn(factoryArgs); this.allInstances.push(spawned); diff --git a/packages/php-wasm/universal/src/lib/php-worker.ts b/packages/php-wasm/universal/src/lib/php-worker.ts index 16e37f5a97..50f5b2c3b0 100644 --- a/packages/php-wasm/universal/src/lib/php-worker.ts +++ b/packages/php-wasm/universal/src/lib/php-worker.ts @@ -99,6 +99,10 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable { }); } + public __internal_getRequestHandler() { + return _private.get(this)!.requestHandler; + } + /** * @internal * @deprecated diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index b94e1f339f..6435b86f55 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -72,6 +72,8 @@ export class PHP implements Disposable { #messageListeners: MessageListener[] = []; #mounts: Record = {}; requestHandler?: PHPRequestHandler; + private cliCalled = false; + private runStreamCalled = false; /** * An exclusive lock that prevent multiple requests from running at @@ -323,6 +325,15 @@ export class PHP implements Disposable { this[__private__dont__use].FS.chdir(path); } + /** + * Changes the permissions of a file or directory. + * @param path - The path to the file or directory. + * @param mode - The new permissions. + */ + chmod(path: string, mode: number) { + this[__private__dont__use].FS.chmod(path, mode); + } + /** * Do not use. Use new PHPRequestHandler() instead. * @deprecated @@ -416,25 +427,14 @@ export class PHP implements Disposable { ); if (syncResponse.exitCode !== 0) { - logger.warn(`PHP.run() output was:`, syncResponse.text); - const error = new PHPExecutionFailureError( - `PHP.run() failed with exit code ${syncResponse.exitCode} and the following output: ` + - syncResponse.errors + - '\n\n' + - syncResponse.text, + // Legacy behavior: throw if PHP exited with a non-zero exit code. + // It could be a WASM crash, but it could be a PHP userland error such + // as "Fatal error: Uncaught Error: Call to undefined function no_such_function()". + throw new PHPExecutionFailureError( + `PHP.run() failed with exit code ${syncResponse.exitCode}. \n\n=== Stdout ===\n ${syncResponse.text}\n\n=== Stderr ===\n ${syncResponse.errors}`, syncResponse, 'request' ) as PHPExecutionFailureError; - logger.error(error); - this.dispatchEvent({ - type: 'request.error', - error: new Error( - 'PHP.run() failed with exit code ' + syncResponse.exitCode - ), - // Distinguish between PHP request and PHP-wasm errors - source: 'request', - }); - throw error; } return syncResponse; @@ -531,6 +531,14 @@ export class PHP implements Disposable { * @returns A StreamedPHPResponse object. */ async runStream(request: PHPRunOptions): Promise { + if (this.cliCalled) { + throw new Error( + 'php.runStream() can only be called if php.cli() was not called before. The two methods set a conflicting ' + + 'C-level global state.' + ); + } + this.runStreamCalled = true; + /* * Prevent multiple requests from running at the same time. * For example, if a request is made to a PHP file that @@ -607,12 +615,19 @@ export class PHP implements Disposable { // Free up resources when the response is done await streamedResponsePromise .catch((error) => { + /** + * Dispatch a request.error event for any global crash handlers. For example, + * Playground web uses this to automatically display a "Report crash" modal. + */ this.dispatchEvent({ type: 'request.error', error: error as Error, // Distinguish between PHP request and PHP-wasm errors source: (error as any).source ?? 'php-wasm', }); + + // Rethrow the error. We don't want to swallow it. + throw error; }) .finally(() => { if (heapBodyPointer) { @@ -621,6 +636,10 @@ export class PHP implements Disposable { }) .finally(() => { release(); + /** + * Notify the filesystem journal and rotatePHPRuntime() that we've handled + * another request. + */ this.dispatchEvent({ type: 'request.end', }); @@ -911,17 +930,12 @@ export class PHP implements Disposable { * get crashes and unhandled promise rejections without any useful error * messages or meaningful stack traces. */ - const exit = await Promise.race([ + const exitCode = await Promise.race([ executionFn(), new Promise((_, reject) => { errorListener = (e: ErrorEvent) => { - logger.error(e); - logger.error(e.error); if (!isExitCode(e.error)) { - const rethrown = new Error('Rethrown'); - rethrown.cause = e.error; - (rethrown as any).betterMessage = e.message; - reject(rethrown); + reject(e.error); } }; this.#wasmErrorsTarget?.addEventListener( @@ -931,16 +945,17 @@ export class PHP implements Disposable { ); }), ]); - return exit; + return exitCode; } catch (e) { /** * Emscripten sometimes communicates program exit as an error. Let's * turn exit code errors into integers again. */ if (isExitCode(e)) { - return e.exitCode; + return e.exitCode ?? (e as any).status; } + // Non-exit-code errors indicate a WASM runtime crash. Let's clean up and throw. stdout.controller.error(e); stderr.controller.error(e); headers.controller.error(e); @@ -963,15 +978,7 @@ export class PHP implements Disposable { (this as any).functionsMaybeMissingFromAsyncify = getFunctionsMaybeMissingFromAsyncify(); - const err = e as Error; - const message = ( - 'betterMessage' in err ? err.betterMessage : err.message - ) as string; - - const rethrown = new Error(message); - rethrown.cause = err; - logger.error(rethrown); - throw rethrown; + throw e; } finally { if (!streamsClosed) { stdout.controller.close(); @@ -1268,6 +1275,18 @@ export class PHP implements Disposable { argv: string[], options: { env?: Record } = {} ): Promise { + if (this.cliCalled) { + throw new Error( + 'php.cli() can only be called once. The method sets a C-level global state that does not allow repeated calls.' + ); + } + if (this.runStreamCalled) { + throw new Error( + 'php.cli() can only be called if php.runStream() was not called before. The two methods set a conflicting ' + + 'C-level global state.' + ); + } + this.cliCalled = true; const release = await this.semaphore.acquire(); const env = options.env || {}; diff --git a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts new file mode 100644 index 0000000000..8dfe62740c --- /dev/null +++ b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts @@ -0,0 +1,97 @@ +import { createSpawnHandler } from '@php-wasm/util'; +import type { PHPProcessManager } from './php-process-manager'; + +/** + * An isomorphic proc_open() handler that implements typical shell in TypeScript + * without relying on a server runtime. It can be used in the browser and Node.js + * alike whenever you need to spawn a PHP subprocess, query the terminal size, etc. + * It is open for future expansion if more shell or busybox calls are needed, but + * advanced shell features such as piping, stream redirection etc. are outside of + * the scope of this minimal handler. If they become vital at any point, let's + * explore bringing in an actual shell implementation or at least a proper command + * parser. + */ +export function sandboxedSpawnHandlerFactory( + processManager: PHPProcessManager +) { + return createSpawnHandler(async function (args, processApi, options) { + processApi.notifySpawn(); + if (args[0] === 'exec') { + args.shift(); + } + + if (args[0].endsWith('.php') || args[0].endsWith('.phar')) { + args.unshift('php'); + } + + const binaryName = args[0].split('/').pop(); + + // Mock programs required by wp-cli: + if ( + args[0] === '/usr/bin/env' && + args[1] === 'stty' && + args[2] === 'size' + ) { + // These numbers are hardcoded because this + // spawnHandler is transmitted as a string to + // the PHP backend and has no access to local + // scope. It would be nice to find a way to + // transfer / proxy a live object instead. + // @TODO: Do not hardcode this + processApi.stdout(`18 140`); + processApi.exit(0); + } else if (binaryName === 'tput' && args[1] === 'cols') { + processApi.stdout(`140`); + processApi.exit(0); + } else if (binaryName === 'less') { + processApi.on('stdin', (data: Uint8Array) => { + processApi.stdout(data); + }); + processApi.flushStdin(); + processApi.exit(0); + } else if (binaryName === 'php') { + const { php, reap } = await processManager.acquirePHPInstance({ + considerPrimary: false, + }); + + php.chdir(options.cwd as string); + try { + // Figure out more about setting env, putenv(), etc. + const result = await php.cli(args, { + env: { + ...options.env, + SCRIPT_PATH: args[1], + // Set SHELL_PIPE to 0 to ensure WP-CLI formats + // the output as ASCII tables. + // @see https://github.com/wp-cli/wp-cli/issues/1102 + SHELL_PIPE: '0', + }, + }); + + result.stdout.pipeTo( + new WritableStream({ + write(chunk) { + processApi.stdout(chunk); + }, + }) + ); + result.stderr.pipeTo( + new WritableStream({ + write(chunk) { + processApi.stderr(chunk); + }, + }) + ); + processApi.exit(await result.exitCode); + } catch (e) { + // An exception here means the PHP runtime has crashed. + processApi.exit(1); + throw e; + } finally { + reap(); + } + } else { + processApi.exit(1); + } + }); +} diff --git a/packages/php-wasm/universal/src/lib/serialize-error.ts b/packages/php-wasm/universal/src/lib/serialize-error.ts new file mode 100644 index 0000000000..014434feb8 --- /dev/null +++ b/packages/php-wasm/universal/src/lib/serialize-error.ts @@ -0,0 +1,304 @@ +/** + * `serialize-error` package wrapped as a single file for compatibility + * with both CJS and ESM. + * + * @see https://github.com/sindresorhus/serialize-error + */ +const list = [ + // Native ES errors https://262.ecma-international.org/12.0/#sec-well-known-intrinsic-objects + Error, + EvalError, + RangeError, + ReferenceError, + SyntaxError, + TypeError, + URIError, + AggregateError, + + // Built-in errors + globalThis.DOMException, + + // Node-specific errors + // https://nodejs.org/api/errors.html + (globalThis as any).AssertionError, + (globalThis as any).SystemError, +] + // Non-native Errors are used with `globalThis` because they might be missing. This filter drops + // them when undefined. + .filter(Boolean) + .map((constructor) => [constructor.name, constructor]); + +export type ErrorObject = { + name?: string; + message?: string; + stack?: string; + cause?: unknown; + code?: string; +} & Record; + +export const errorConstructors = new Map(list as any); + +export function addKnownErrorConstructor(constructor: any) { + const { name } = constructor; + if (errorConstructors.has(name)) { + throw new Error(`The error constructor "${name}" is already known.`); + } + + try { + // eslint-disable-next-line no-new -- It just needs to be verified + new constructor(); + } catch (error) { + throw new Error(`The error constructor "${name}" is not compatible`, { + cause: error, + }); + } + + errorConstructors.set(name, constructor); +} + +export class NonError extends Error { + override name = 'NonError'; + + constructor(message: any) { + super(NonError._prepareSuperMessage(message)); + } + + static _prepareSuperMessage(message: any) { + try { + return JSON.stringify(message); + } catch { + return String(message); + } + } +} + +const errorProperties = [ + { + property: 'name', + enumerable: false, + }, + { + property: 'message', + enumerable: false, + }, + { + property: 'stack', + enumerable: false, + }, + { + property: 'code', + enumerable: true, + }, + { + property: 'cause', + enumerable: false, + }, + { + property: 'errors', + enumerable: false, + }, +]; + +const toJsonWasCalled = new WeakSet(); + +const toJSON = (from: any) => { + toJsonWasCalled.add(from); + const json = from.toJSON(); + toJsonWasCalled.delete(from); + return json; +}; + +const newError = (name: any) => { + const ErrorConstructor = errorConstructors.get(name) ?? (Error as any); + return ErrorConstructor === AggregateError + ? new ErrorConstructor([]) + : new ErrorConstructor(); +}; + +// eslint-disable-next-line complexity +const destroyCircular = ({ + from, + seen, + to, + forceEnumerable, + maxDepth, + depth, + useToJSON, + serialize, +}: { + from?: any; + seen: any[]; + to?: any; + forceEnumerable: boolean; + maxDepth: number; + depth: number; + useToJSON: boolean; + serialize: boolean; +}) => { + if (!to) { + if (Array.isArray(from)) { + to = []; + } else if (!serialize && isErrorLike(from)) { + to = newError(from.name); + } else { + to = {}; + } + } + + seen.push(from); + + if (depth >= maxDepth) { + return to; + } + + if ( + useToJSON && + typeof from.toJSON === 'function' && + !toJsonWasCalled.has(from) + ) { + return toJSON(from); + } + + const continueDestroyCircular = (value: any) => + destroyCircular({ + from: value, + seen: [...seen], + forceEnumerable, + maxDepth, + depth, + useToJSON, + serialize, + }); + + for (const [key, value] of Object.entries(from)) { + if ( + value && + value instanceof Uint8Array && + value.constructor.name === 'Buffer' + ) { + to[key] = '[object Buffer]'; + continue; + } + + // TODO: Use `stream.isReadable()` when targeting Node.js 18. + if ( + value !== null && + typeof value === 'object' && + typeof (value as any).pipe === 'function' + ) { + to[key] = '[object Stream]'; + continue; + } + + if (typeof value === 'function') { + continue; + } + + if (!value || typeof value !== 'object') { + // Gracefully handle non-configurable errors like `DOMException`. + try { + to[key] = value; + } catch { + // ignore + } + + continue; + } + + if (!seen.includes(from[key])) { + depth++; + to[key] = continueDestroyCircular(from[key]); + + continue; + } + + to[key] = '[Circular]'; + } + + if (serialize || to instanceof Error) { + for (const { property, enumerable } of errorProperties) { + if (from[property] !== undefined && from[property] !== null) { + Object.defineProperty(to, property, { + value: + isErrorLike(from[property]) || + Array.isArray(from[property]) + ? continueDestroyCircular(from[property]) + : from[property], + enumerable: forceEnumerable ? true : enumerable, + configurable: true, + writable: true, + }); + } + } + } + + return to; +}; + +export function serializeError(value: any, options: any = {}) { + const { maxDepth = Number.POSITIVE_INFINITY, useToJSON = true } = options; + + if (typeof value === 'object' && value !== null) { + return destroyCircular({ + from: value, + seen: [], + forceEnumerable: true, + maxDepth, + depth: 0, + useToJSON, + serialize: true, + }); + } + + // People sometimes throw things besides Error objects… + if (typeof value === 'function') { + // `JSON.stringify()` discards functions. We do too, unless a function is thrown directly. + // We intentionally use `||` because `.name` is an empty string for anonymous functions. + return `[Function: ${value.name || 'anonymous'}]`; + } + + return value; +} + +export function deserializeError(value: any, options: any = {}) { + const { maxDepth = Number.POSITIVE_INFINITY } = options; + + if (value instanceof Error) { + return value; + } + + if (isMinimumViableSerializedError(value)) { + return destroyCircular({ + from: value, + seen: [], + to: newError(value.name), + maxDepth, + depth: 0, + serialize: false, + } as any); + } + + return new NonError(value); +} + +export function isErrorLike(value: any) { + return ( + Boolean(value) && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.message === 'string' && + typeof value.stack === 'string' + ); +} + +// Used as a weak check for immediately-passed objects, whereas `isErrorLike` is used for nested +// values to avoid bad detection +function isMinimumViableSerializedError(value: any) { + // @ts-ignore + return ( + Boolean(value) && + typeof value === 'object' && + typeof value.message === 'string' && + !Array.isArray(value) + ); +} diff --git a/packages/php-wasm/universal/src/lib/wasm-error-reporting.ts b/packages/php-wasm/universal/src/lib/wasm-error-reporting.ts index 9093c03ce1..53285913d5 100644 --- a/packages/php-wasm/universal/src/lib/wasm-error-reporting.ts +++ b/packages/php-wasm/universal/src/lib/wasm-error-reporting.ts @@ -72,10 +72,8 @@ export function improveWASMErrorReporting(runtime: Runtime) { ); if (target.hasListeners()) { - const event = new ErrorEvent('error', { - error: e, - message: clearMessage, - }); + e.message = clearMessage; + const event = new ErrorEvent('error', { error: e }); target.dispatchEvent(event); throw e; } @@ -105,7 +103,7 @@ export function clarifyErrorMessage( if (!asyncifyStack) { betterMessage += `\n\nThis stack trace is lacking. For a better one initialize \n` + - `the PHP runtime with { debug: true }, e.g. PHPNode.load('8.1', { debug: true }).\n\n`; + `the PHP runtime with debug: true, e.g. loadNodeRuntime('8.1', { emscriptenOptions: { debug: true } }).\n\n`; } // Extract all the PHP functions from the entire error chain. @@ -127,6 +125,8 @@ export function clarifyErrorMessage( betterMessage += ` * ${fn}\n`; } + betterMessage += `Original error message: ${crypticError.message}\n`; + return betterMessage; } return crypticError.message; diff --git a/packages/php-wasm/util/tsconfig.lib.json b/packages/php-wasm/util/tsconfig.lib.json index 21e6a5529a..6e62000efa 100644 --- a/packages/php-wasm/util/tsconfig.lib.json +++ b/packages/php-wasm/util/tsconfig.lib.json @@ -8,7 +8,8 @@ "include": [ "src/**/*.ts", "../universal/src/lib/iterate-files.ts", - "../universal/src/lib/stream-write-to-php.ts" + "../universal/src/lib/stream-write-to-php.ts", + "../universal/src/lib/error-reporting.ts" ], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/packages/playground/blueprints/public/blueprints.phar b/packages/playground/blueprints/public/blueprints.phar deleted file mode 100755 index 3e11b24952..0000000000 Binary files a/packages/playground/blueprints/public/blueprints.phar and /dev/null differ diff --git a/packages/playground/blueprints/src/lib/v2.spec.ts b/packages/playground/blueprints/src/lib/v2.spec.ts deleted file mode 100644 index 5c3e3182f0..0000000000 --- a/packages/playground/blueprints/src/lib/v2.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { loadNodeRuntime } from '@php-wasm/node'; -import type { PHPProcessManager, PHPResponse } from '@php-wasm/universal'; -import { RecommendedPHPVersion } from '@wp-playground/common'; -import type { PHPRequestHandler } from '@php-wasm/universal'; -import { bootRequestHandler } from '@wp-playground/wordpress'; -import { runBlueprintV2 } from './v2'; -import { rootCertificates } from 'node:tls'; -import { createSpawnHandler, phpVar } from '@php-wasm/util'; -import { logger } from '@php-wasm/logger'; - -describe('V2 runner', () => { - let handler: PHPRequestHandler; - - beforeEach(async () => { - handler = await bootRequestHandler({ - createPhpRuntime: async () => - await loadNodeRuntime(RecommendedPHPVersion), - sapiName: 'cli', - siteUrl: 'http://playground-domain/', - phpIniEntries: { - 'openssl.cafile': '/internal/shared/ca-bundle.crt', - }, - createFiles: { - '/internal/shared/ca-bundle.crt': rootCertificates.join('\n'), - }, - spawnHandler: spawnHandlerFactory, - }); - }); - - // @TODO: Unskip this test. It needs the rest of the https://github.com/WordPress/wordpress-playground/pull/2238 to be merged - // before it will pass. - it.skip( - 'should run the runner', - async () => { - const { php } = await handler.processManager.acquirePHPInstance(); - const result = await runBlueprintV2({ - php: php as any, - blueprint: '{"version":2}', - siteUrl: 'http://playground-domain/', - documentRoot: '/wordpress', - hooks: { - afterBlueprintTargetResolved: async () => { - console.log('Blueprint target resolved'); - process.exit(0); - }, - }, - }); - expect(await result?.stdoutText).toBe('Hello, World!'); - }, - { - timeout: 60000, - } - ); -}); - -export function spawnHandlerFactory(processManager: PHPProcessManager) { - return createSpawnHandler(async function (args, processApi, options) { - console.log('Spawn handler called', args); - processApi.notifySpawn(); - if (args[0] === 'exec') { - args.shift(); - } - - if (args[0].endsWith('.php')) { - args.unshift('php'); - } - - // Mock programs required by wp-cli: - if ( - args[0] === '/usr/bin/env' && - args[1] === 'stty' && - args[2] === 'size' - ) { - // These numbers are hardcoded because this - // spawnHandler is transmitted as a string to - // the PHP backend and has no access to local - // scope. It would be nice to find a way to - // transfer / proxy a live object instead. - // @TODO: Do not hardcode this - processApi.stdout(`18 140`); - processApi.exit(0); - } else if (args[0] === 'tput' && args[1] === 'cols') { - processApi.stdout(`140`); - processApi.exit(0); - } else if (args[0] === 'less') { - processApi.on('stdin', (data: Uint8Array) => { - processApi.stdout(data); - }); - processApi.flushStdin(); - processApi.exit(0); - } else if (args[0] === 'fetch') { - processApi.flushStdin(); - fetch(args[1]).then(async (res) => { - const reader = res.body?.getReader(); - if (!reader) { - processApi.exit(1); - return; - } - while (true) { - const { done, value } = await reader.read(); - if (done) { - processApi.exit(0); - break; - } - processApi.stdout(value); - } - }); - return; - } else if (args[0] === 'php') { - const { php, reap } = await processManager.acquirePHPInstance(); - - let result: PHPResponse | undefined = undefined; - try { - // @TODO: Run the actual PHP CLI SAPI instead of - // interpreting the arguments and emulating - // the CLI constants and globals. - const cliBootstrapScript = ` void | Promise; - beforeWordPressFiles?: (php: UniversalPHP) => void | Promise; - onProgress?: (progress: number, caption: string) => void; - /** - * A hook that is called when an error occurs. It provides succinct - * error messages and structured details. Useful for reporting specific - * errors to the user without displaying the full stack trace. - * - * @param message The error message. - * @param details The error details. - */ - onError?: (message: string, details?: PHPExceptionDetails) => void; - }; -} - -export type PHPExceptionDetails = { - exception: string; - message: string; - file: string; - line: number; - trace: string; -}; - -export async function runBlueprintV2(options: RunV2Options) { - const php = options.php; - const onProgress = options.hooks?.onProgress || (() => {}); - const onError = options.hooks?.onError || (() => {}); - - // beforeWordPressFiles - if (options.hooks?.beforeWordPressFiles) { - await options.hooks.beforeWordPressFiles(php); - } - const file = await getV2Runner(); - php.writeFile( - '/tmp/blueprints.phar', - new Uint8Array(await file.arrayBuffer()) - ); - - const parsedBlueprintDeclaration = parseBlueprintDeclaration( - options.blueprint - ); - let blueprintReference = ''; - switch (parsedBlueprintDeclaration.type) { - case 'inline-file': - php.writeFile( - '/tmp/blueprint.json', - parsedBlueprintDeclaration.contents - ); - blueprintReference = '/tmp/blueprint.json'; - break; - case 'file-reference': - blueprintReference = parsedBlueprintDeclaration.reference; - break; - } - - // @TODO: Unbind this listener after a successful run. - // Maybe propagate messages via addEventListener etc? - await php.onMessage(async (message) => { - try { - const parsed = - typeof message === 'string' ? JSON.parse(message) : message; - if (!parsed) { - return; - } - switch (parsed.type) { - case 'blueprint.target_resolved': - // @TODO: Rethink these debug constants. We shouldn't - // always set them, right? - php.defineConstant('WP_DEBUG', true); - php.defineConstant('WP_DEBUG_LOG', true); - php.defineConstant('WP_DEBUG_DISPLAY', false); - - /* - * Add required constants to "wp-config.php" if they are not already defined. - * This is needed, because some WordPress backups and exports may not include - * definitions for some of the necessary constants. - */ - await ensureWpConfig(php, options.documentRoot); - - if (options.hooks?.afterBlueprintTargetResolved) { - await options.hooks.afterBlueprintTargetResolved(php); - } - break; - case 'blueprint.progress': - onProgress?.( - parsed.progress, - parsed.caption || 'Running the Blueprint' - ); - break; - case 'blueprint.error': - onError?.(parsed.message, parsed.details); - break; - } - } catch (e) { - logger.warn('Failed to parse message as JSON:', message, e); - } - }); - - await php?.writeFile('/tmp/stdout', ''); - await php?.writeFile('/tmp/stderror', ''); - await php?.writeFile( - '/tmp/run-blueprints.php', - `writeJsonMessage([ - 'type' => 'blueprint.progress', - 'progress' => round($progress, 2), - 'caption' => $caption - ]); - } - - public function reportError(string $message, ?Throwable $exception = null): void { - $errorData = [ - 'type' => 'blueprint.error', - 'message' => $message - ]; - - if ($exception) { - $errorData['details'] = [ - 'exception' => get_class($exception), - 'message' => $exception->getMessage(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTraceAsString() - ]; - } - - $this->writeJsonMessage($errorData); - } - - public function reportCompletion(string $message): void { - $this->writeJsonMessage([ - 'type' => 'blueprint.completion', - 'message' => $message - ]); - } - - public function close(): void {} - - private function writeJsonMessage(array $data): void { - post_message_to_js(json_encode($data)); - } -} - return new PlaygroundProgressReporter(); -} -playground_add_filter('blueprint.progress_reporter', 'playground_progress_reporter'); - -require( "/tmp/blueprints.phar" ); -` - ); - - // @TODO: Remove this cast. Add the cli() method to UniversalPHP. - return await (php as any).cli([ - 'php', - '/tmp/run-blueprints.php', - 'exec', - blueprintReference, - '--site-path=/wordpress', - `--site-url=${options.siteUrl}`, - '--db-engine=sqlite', - // '--truncate-new-site-directory=true', - ]); -} - -export type BlueprintV2Declaration = string | BlueprintDeclaration | undefined; -export type ParsedBlueprintV2Declaration = - | { type: 'inline-file'; contents: string } - | { type: 'file-reference'; reference: string }; - -export function parseBlueprintDeclaration( - source: BlueprintV2Declaration | ParsedBlueprintV2Declaration -): ParsedBlueprintV2Declaration { - if ( - typeof source === 'object' && - 'type' in source && - ['inline-file', 'file-reference'].includes(source.type) - ) { - return source; - } - if (!source) { - return { - type: 'inline-file', - contents: '{}', - }; - } - if (typeof source !== 'string') { - // If source is an object, assume it's a Blueprint declaration object and - // convert it to a JSON string. - return { - type: 'inline-file', - contents: JSON.stringify(source), - }; - } - try { - // If source is valid JSON, return it as is. - JSON.parse(source); - return { - type: 'inline-file', - contents: source, - }; - } catch { - return { - type: 'file-reference', - reference: source, - }; - } -} - -export async function getV2Runner(): Promise { - let data = null; - /** - * Only load the v2 runner via node:fs when running in Node.js. - */ - if (typeof process !== 'undefined' && process.versions?.node) { - let path = v2_runner_url; - if (path.startsWith('/@fs/')) { - path = path.slice(4); - } - - const { readFile } = await import('node:fs/promises'); - data = await readFile(path); - } else { - const response = await fetch(v2_runner_url); - data = await response.blob(); - } - return new File([data], `blueprints.phar`, { - type: 'application/zip', - }); -} diff --git a/packages/playground/blueprints/vite.config.ts b/packages/playground/blueprints/vite.config.ts index df12a4f7bb..422e4446ff 100644 --- a/packages/playground/blueprints/vite.config.ts +++ b/packages/playground/blueprints/vite.config.ts @@ -73,6 +73,14 @@ export default defineConfig({ external: getExternalModules(), }, }, + resolve: { + // @ts-ignore + alias: { + // This makes sure Vite doesn't stub it + fs: false, + 'fs/promises': false, + }, + }, test: { globals: true, diff --git a/packages/playground/cli/public/blueprints.phar b/packages/playground/cli/public/blueprints.phar new file mode 100755 index 0000000000..6ac80590b4 Binary files /dev/null and b/packages/playground/cli/public/blueprints.phar differ diff --git a/packages/playground/cli/src/cli-auto-mount.ts b/packages/playground/cli/src/cli-auto-mount.ts deleted file mode 100644 index 8249b88e3a..0000000000 --- a/packages/playground/cli/src/cli-auto-mount.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { basename, join } from 'path'; -import type { - BlueprintDeclaration, - StepDefinition, -} from '@wp-playground/blueprints'; -import fs from 'fs'; -import type { RunCLIArgs } from './run-cli'; -import type { Mount } from './mount'; - -export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { - const path = process.cwd(); - - const mount = [...(args.mount || [])]; - const mountBeforeInstall = [...(args.mountBeforeInstall || [])]; - - if (isPluginDirectory(path)) { - const pluginName = basename(path); - mount.push({ - hostPath: path, - vfsPath: `/wordpress/wp-content/plugins/${pluginName}`, - }); - } else if (isThemeDirectory(path)) { - const themeName = basename(path); - mount.push({ - hostPath: path, - vfsPath: `/wordpress/wp-content/themes/${themeName}`, - }); - } else if (containsWpContentDirectories(path)) { - mount.push(...wpContentMounts(path)); - } else if (containsFullWordPressInstallation(path)) { - /** - * We don't want Playground and WordPress to modify the OS filesystem on their own - * by creating files like wp-config.php or wp-content/db.php. - * To ensure WordPress can write to the /wordpress/ and /wordpress/wp-content/ directories, - * we leave these directories as MEMFS nodes and mount individual files - * and directories into them instead of mounting the entire directory as a NODEFS node. - */ - const files = fs.readdirSync(path); - const mounts: Mount[] = []; - for (const file of files) { - if (file.startsWith('wp-content')) { - continue; - } - mounts.push({ - hostPath: `${path}/${file}`, - vfsPath: `/wordpress/${file}`, - }); - } - mountBeforeInstall.push( - ...mounts, - ...wpContentMounts(join(path, 'wp-content')) - ); - } else { - /** - * By default, mount the current working directory as the Playground root. - * This allows users to run and PHP or HTML files using the Playground CLI. - */ - mount.push({ hostPath: path, vfsPath: '/wordpress' }); - } - - const blueprint = (args.blueprint as BlueprintDeclaration) || {}; - blueprint.steps = [...(blueprint.steps || []), ...getSteps(path)]; - - /** - * If Playground is mounting a full WordPress directory, - * it doesn't need to setup WordPress. - */ - const skipWordPressSetup = - args.skipWordPressSetup || containsFullWordPressInstallation(path); - - return { - ...args, - blueprint, - mount, - mountBeforeInstall, - skipWordPressSetup, - } as RunCLIArgs; -} - -export function containsFullWordPressInstallation(path: string): boolean { - const files = fs.readdirSync(path); - return ( - files.includes('wp-admin') && - files.includes('wp-includes') && - files.includes('wp-content') - ); -} - -export function containsWpContentDirectories(path: string): boolean { - const files = fs.readdirSync(path); - return ( - files.includes('themes') || - files.includes('plugins') || - files.includes('mu-plugins') || - files.includes('uploads') - ); -} - -export function isThemeDirectory(path: string): boolean { - const files = fs.readdirSync(path); - if (!files.includes('style.css')) { - return false; - } - const styleCssContent = fs.readFileSync(join(path, 'style.css'), 'utf8'); - const themeNameRegex = /^(?:[ \t]*<\?php)?[ \t/*#@]*Theme Name:(.*)$/im; - return !!themeNameRegex.exec(styleCssContent); -} - -export function isPluginDirectory(path: string): boolean { - const files = fs.readdirSync(path); - const pluginNameRegex = /^(?:[ \t]*<\?php)?[ \t/*#@]*Plugin Name:(.*)$/im; - const pluginNameMatch = files - .filter((file) => file.endsWith('.php')) - .find((file) => { - const fileContent = fs.readFileSync(join(path, file), 'utf8'); - return !!pluginNameRegex.exec(fileContent); - }); - return !!pluginNameMatch; -} - -/** - * Returns a list of files and directories in the wp-content directory - * to be mounted individually. - * - * This is needed because WordPress needs to be able to write to the - * wp-content directory without Playground modifying the OS filesystem. - * - * See expandAutoMounts for more details. - */ -export function wpContentMounts(wpContentDir: string): Mount[] { - const files = fs.readdirSync(wpContentDir); - return ( - files - /** - * index.php is added by WordPress automatically and - * can't be mounted from the current working directory - * because it already exists. - * - * Because index.php should be empty, it's safe to not include it. - */ - .filter((file) => !file.startsWith('index.php')) - .map((file) => ({ - hostPath: `${wpContentDir}/${file}`, - vfsPath: `/wordpress/wp-content/${file}`, - })) - ); -} - -export function getSteps(path: string): StepDefinition[] { - if (isPluginDirectory(path)) { - return [ - { - step: 'activatePlugin', - pluginPath: `/wordpress/wp-content/plugins/${basename(path)}`, - }, - ]; - } else if (isThemeDirectory(path)) { - return [ - { - step: 'activateTheme', - themeFolderName: basename(path), - }, - ]; - } else if ( - containsWpContentDirectories(path) || - containsFullWordPressInstallation(path) - ) { - /** - * Playground needs to ensure there is an active theme. - * Otherwise when WordPress loads it will show a white screen. - */ - return [ - { - step: 'runPHP', - code: `exists()) { - $themes = wp_get_themes(); - if (count($themes) > 0) { - $themeName = array_keys($themes)[0]; - switch_theme($themeName); - } - } - `, - }, - ]; - } - return []; -} diff --git a/packages/playground/cli/src/cli.ts b/packages/playground/cli/src/cli.ts index af267ae2d9..1e0e0a5861 100644 --- a/packages/playground/cli/src/cli.ts +++ b/packages/playground/cli/src/cli.ts @@ -1,8 +1,13 @@ import { parseOptionsAndRunCLI } from './run-cli'; // Do not await this as top-level await is not supported in all environments. -parseOptionsAndRunCLI().catch(() => { - // process.exit(1); is here and not in parseOptionsAndRunCLI() - // so that we can unit test the failure modes with try/catch. - process.exit(1); -}); +parseOptionsAndRunCLI().then( + () => { + // Do nothing, just keep the server alive. + }, + () => { + // process.exit(1); is here and not in parseOptionsAndRunCLI() + // so that we can unit test the failure modes with try/catch. + process.exit(1); + } +); diff --git a/packages/playground/cli/src/download.ts b/packages/playground/cli/src/download.ts deleted file mode 100644 index 9ae32b9297..0000000000 --- a/packages/playground/cli/src/download.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { EmscriptenDownloadMonitor } from '@php-wasm/progress'; -import fs from 'fs-extra'; -import os from 'os'; -import path, { basename } from 'path'; - -export const CACHE_FOLDER = path.join(os.homedir(), '.wordpress-playground'); - -export async function fetchSqliteIntegration( - monitor: EmscriptenDownloadMonitor -) { - const sqliteZip = await cachedDownload( - 'https://github.com/WordPress/sqlite-database-integration/archive/refs/heads/develop.zip', - 'sqlite.zip', - monitor - ); - return sqliteZip; -} - -// @TODO: Support HTTP cache, invalidate the local file if the remote file has -// changed -export async function cachedDownload( - remoteUrl: string, - cacheKey: string, - monitor: EmscriptenDownloadMonitor -) { - const artifactPath = path.join(CACHE_FOLDER, cacheKey); - if (!fs.existsSync(artifactPath)) { - fs.ensureDirSync(CACHE_FOLDER); - await downloadTo(remoteUrl, artifactPath, monitor); - } - return readAsFile(artifactPath); -} - -async function downloadTo( - remoteUrl: string, - localPath: string, - monitor: EmscriptenDownloadMonitor -) { - const response = await monitor.monitorFetch(fetch(remoteUrl)); - const reader = response.body!.getReader(); - const tmpPath = `${localPath}.partial`; - const writer = fs.createWriteStream(tmpPath); - while (true) { - const { done, value } = await reader.read(); - if (value) { - writer.write(value); - } - if (done) { - break; - } - } - writer.close(); - if (!writer.closed) { - await new Promise((resolve, reject) => { - writer.on('finish', () => { - fs.renameSync(tmpPath, localPath); - resolve(null); - }); - writer.on('error', (err: any) => { - fs.removeSync(tmpPath); - reject(err); - }); - }); - } -} - -export function readAsFile(path: string, fileName?: string): File { - return new File([fs.readFileSync(path)], fileName ?? basename(path)); -} diff --git a/packages/playground/cli/src/is-valid-wordpress-slug.ts b/packages/playground/cli/src/is-valid-wordpress-slug.ts deleted file mode 100644 index 4fdd957961..0000000000 --- a/packages/playground/cli/src/is-valid-wordpress-slug.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Checks if the given version string is a valid WordPress version. - * - * The Regex is based on the releases on https://wordpress.org/download/releases/#betas - * The version string can be one of the following formats: - * - "latest" - * - "trunk" - * - "nightly" - * - "x.y" (x and y are integers) e.g. "6.2" - * - "x.y.z" (x, y and z are integers) e.g. "6.2.1" - * - "x.y.z-betaN" (N is an integer) e.g. "6.2.1-beta1" - * - "x.y.z-RCN" (N is an integer) e.g. "6.2-RC1" - * - * @param version The version string to check. - * @returns A boolean value indicating whether the version string is a valid WordPress version. - */ -export function isValidWordPressSlug(version: string): boolean { - const versionPattern = - /^latest$|^trunk$|^nightly$|^(?:(\d+)\.(\d+)(?:\.(\d+))?)((?:-beta(?:\d+)?)|(?:-RC(?:\d+)?))?$/; - return versionPattern.test(version); -} diff --git a/packages/playground/cli/src/mount.ts b/packages/playground/cli/src/mount.ts deleted file mode 100644 index 20c2d9fa9b..0000000000 --- a/packages/playground/cli/src/mount.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { existsSync } from 'fs'; -import path from 'path'; -import { createNodeFsMountHandler } from '@php-wasm/node'; -import type { PHP } from '@php-wasm/universal'; - -export interface Mount { - hostPath: string; - vfsPath: string; -} - -/** - * Parse an array of mount argument strings where the host path and VFS path - * are separated by a colon. - * - * Example: - * parseMountWithDelimiterArguments( [ '/host/path:/vfs/path', '/host/path:/vfs/path' ] ) - * // returns: - * [ - * { hostPath: '/host/path', vfsPath: '/vfs/path' }, - * { hostPath: '/host/path', vfsPath: '/vfs/path' } - * ] - * - * @param mounts - An array of mount argument strings separated by a colon. - * @returns An array of Mount objects. - */ -export function parseMountWithDelimiterArguments(mounts: string[]): Mount[] { - const parsedMounts = []; - for (const mount of mounts) { - const mountParts = mount.split(':'); - if (mountParts.length !== 2) { - throw new Error(`Invalid mount format: ${mount}. - Expected format: /host/path:/vfs/path. - If your path contains a colon, e.g. C:\\myplugin, use the --mount-dir option instead. - Example: --mount-dir C:\\my-plugin /wordpress/wp-content/plugins/my-plugin`); - } - const [hostPath, vfsPath] = mountParts; - if (!existsSync(hostPath)) { - throw new Error(`Host path does not exist: ${hostPath}`); - } - parsedMounts.push({ hostPath, vfsPath }); - } - return parsedMounts; -} - -/** - * Parse an array of mount argument strings where each odd array element is a host path - * and each even element is the VFS path. - * e.g. [ '/host/path', '/vfs/path', '/host/path2', '/vfs/path2' ] - * - * The result will be an array of Mount objects for each host path the - * following element is it's VFS path. - * e.g. [ - * { hostPath: '/host/path', vfsPath: '/vfs/path' }, - * { hostPath: '/host/path2', vfsPath: '/vfs/path2' } - * ] - * - * @param mounts - An array of paths - * @returns An array of Mount objects. - */ -export function parseMountDirArguments(mounts: string[]): Mount[] { - if (mounts.length % 2 !== 0) { - throw new Error('Invalid mount format. Expected: /host/path /vfs/path'); - } - - const parsedMounts = []; - for (let i = 0; i < mounts.length; i += 2) { - const source = mounts[i]; - const vfsPath = mounts[i + 1]; - if (!existsSync(source)) { - throw new Error(`Host path does not exist: ${source}`); - } - parsedMounts.push({ - hostPath: path.resolve(process.cwd(), source), - vfsPath, - }); - } - return parsedMounts; -} - -export function mountResources(php: PHP, mounts: Mount[]) { - for (const mount of mounts) { - php.mkdir(mount.vfsPath); - php.mount(mount.vfsPath, createNodeFsMountHandler(mount.hostPath)); - } -} diff --git a/packages/playground/cli/src/mounts.spec.ts b/packages/playground/cli/src/mounts.spec.ts new file mode 100644 index 0000000000..04c8d40d59 --- /dev/null +++ b/packages/playground/cli/src/mounts.spec.ts @@ -0,0 +1,465 @@ +import path from 'node:path'; +import type { MockInstance } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { expandAutoMounts } from './mounts'; +import type { RunCLIArgs } from './run-cli'; + +describe('expandAutoMounts', () => { + afterEach(() => { + if ((process.cwd as unknown as MockInstance).mockRestore) { + (process.cwd as unknown as MockInstance).mockRestore(); + } + }); + + const createBasicArgs = (): RunCLIArgs => ({ + command: 'server', + php: '8.0', + }); + + describe('plugin directory detection', () => { + test('should mount plugin directory correctly', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/plugin' + ), + vfsPath: '/wordpress/wp-content/plugins/plugin', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([ + { + step: 'activatePlugin', + pluginPath: '/wordpress/wp-content/plugins/plugin', + }, + ]); + }); + + test('should not mount non-plugin directory as plugin', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/not-plugin') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + // Should fall back to default behavior (mount as /wordpress) + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/not-plugin' + ), + vfsPath: '/wordpress', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([]); + }); + }); + + describe('theme directory detection', () => { + test('should mount theme directory correctly', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/theme') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join(__dirname, 'test/mount-examples/theme'), + vfsPath: '/wordpress/wp-content/themes/theme', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([ + { + step: 'activateTheme', + themeDirectoryName: 'theme', + }, + ]); + }); + + test('should not mount non-theme directory as theme', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/not-theme') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + // Should fall back to default behavior (mount as /wordpress) + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/not-theme' + ), + vfsPath: '/wordpress', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([]); + }); + }); + + describe('wp-content directory detection', () => { + test('should mount wp-content directory correctly', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/wp-content') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/wp-content/plugins' + ), + vfsPath: '/wordpress/wp-content/plugins', + }, + { + hostPath: path.join( + __dirname, + 'test/mount-examples/wp-content/themes' + ), + vfsPath: '/wordpress/wp-content/themes', + }, + ]); + const steps = result['additional-blueprint-steps']; + expect(steps).toHaveLength(1); + expect(steps![0]).toEqual({ + step: 'runPHP', + code: { + filename: 'activate-theme.php', + content: expect.stringContaining('wp_get_theme'), + }, + }); + }); + + test('should mount wp-content directory with only themes', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join( + __dirname, + 'test/mount-examples/wp-content-only-themes' + ) + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/wp-content-only-themes/themes' + ), + vfsPath: '/wordpress/wp-content/themes', + }, + ]); + }); + + test('should mount wp-content directory with only mu-plugins', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join( + __dirname, + 'test/mount-examples/wp-content-only-mu-plugins' + ) + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/wp-content-only-mu-plugins/mu-plugins' + ), + vfsPath: '/wordpress/wp-content/mu-plugins', + }, + ]); + }); + }); + + describe('full WordPress installation detection', () => { + test('should mount full WordPress installation correctly', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/wordpress') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + // Should mount individual files except wp-content + expect(result['mount-before-install'] || []).toEqual( + expect.arrayContaining([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/wordpress' + ), + vfsPath: '/wordpress', + }, + ]) + ); + expect(result.mode).toBe('apply-to-existing-site'); + const steps = result['additional-blueprint-steps']; + expect(steps).toHaveLength(1); + expect(steps![0]).toEqual({ + step: 'runPHP', + code: { + filename: 'activate-theme.php', + content: expect.stringContaining('wp_get_theme'), + }, + }); + }); + }); + + describe('default behavior', () => { + test('should mount static HTML directory as default', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/static-html') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/static-html' + ), + vfsPath: '/wordpress', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([]); + expect(result.mode).toBe('mount-only'); + }); + + test('should mount PHP directory as default', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/php') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join(__dirname, 'test/mount-examples/php'), + vfsPath: '/wordpress', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([]); + expect(result.mode).toBe('mount-only'); + }); + + test('should mount empty directory as default', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/nothing') + ); + + const args = createBasicArgs(); + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/nothing' + ), + vfsPath: '/wordpress', + }, + ]); + expect(result['additional-blueprint-steps']).toEqual([]); + expect(result.mode).toBe('mount-only'); + }); + }); + + describe('preserving existing arguments', () => { + test('should preserve existing mounts', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + mount: [ + { + hostPath: '/existing/mount', + vfsPath: '/existing/vfs', + }, + ], + }; + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: '/existing/mount', + vfsPath: '/existing/vfs', + }, + { + hostPath: path.join( + __dirname, + 'test/mount-examples/plugin' + ), + vfsPath: '/wordpress/wp-content/plugins/plugin', + }, + ]); + }); + + test('should preserve existing mountBeforeInstall', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/wordpress') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + 'mount-before-install': [ + { + hostPath: '/existing/before-mount', + vfsPath: '/existing/before-vfs', + }, + ], + }; + const result = expandAutoMounts(args); + + expect(result['mount-before-install'] || []).toEqual( + expect.arrayContaining([ + { + hostPath: '/existing/before-mount', + vfsPath: '/existing/before-vfs', + }, + ]) + ); + // Should also contain the auto-detected mounts + expect( + (result['mount-before-install'] || []).length + ).toBeGreaterThan(1); + }); + + test('should preserve existing blueprint steps', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + 'additional-blueprint-steps': [ + { + step: 'setSiteOptions', + options: { blogname: 'Test Blog' }, + }, + ], + }; + const result = expandAutoMounts(args); + + expect(result['additional-blueprint-steps']).toEqual([ + { + step: 'setSiteOptions', + options: { blogname: 'Test Blog' }, + }, + { + step: 'activatePlugin', + pluginPath: '/wordpress/wp-content/plugins/plugin', + }, + ]); + }); + }); + + describe('edge cases', () => { + test('should handle undefined mount arrays', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + mount: undefined, + 'mount-before-install': undefined, + }; + const result = expandAutoMounts(args); + + expect(result.mount).toEqual([ + { + hostPath: path.join( + __dirname, + 'test/mount-examples/plugin' + ), + vfsPath: '/wordpress/wp-content/plugins/plugin', + }, + ]); + expect(result['mount-before-install']).toEqual([]); + }); + + test('should handle undefined blueprint', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + blueprint: undefined, + }; + const result = expandAutoMounts(args); + + expect(result['additional-blueprint-steps']).toEqual([ + { + step: 'activatePlugin', + pluginPath: '/wordpress/wp-content/plugins/plugin', + }, + ]); + }); + + test('should handle blueprint as string', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + blueprint: '/path/to/blueprint.json', + }; + const result = expandAutoMounts(args); + + // Should preserve the string blueprint as is (steps are not added for string blueprints) + expect(result.blueprint).toBe('/path/to/blueprint.json'); + }); + + test('should return all other arguments unchanged', () => { + vi.spyOn(process, 'cwd').mockReturnValue( + path.join(__dirname, 'test/mount-examples/plugin') + ); + + const args: RunCLIArgs = { + ...createBasicArgs(), + php: '8.1', + port: 3000, + quiet: true, + debug: true, + login: true, + wp: '6.0', + outfile: 'custom.zip', + }; + const result = expandAutoMounts(args); + + expect(result.php).toBe('8.1'); + expect(result.port).toBe(3000); + expect(result.quiet).toBe(true); + expect(result.debug).toBe(true); + expect(result.login).toBe(true); + expect(result.wp).toBe('6.0'); + expect(result.outfile).toBe('custom.zip'); + }); + }); +}); diff --git a/packages/playground/cli/src/mounts.ts b/packages/playground/cli/src/mounts.ts new file mode 100644 index 0000000000..28a21d2594 --- /dev/null +++ b/packages/playground/cli/src/mounts.ts @@ -0,0 +1,222 @@ +import { createNodeFsMountHandler } from '@php-wasm/node'; +import type { PHP } from '@php-wasm/universal'; +import fs, { existsSync } from 'fs'; +import path, { basename, join } from 'path'; +import type { RunCLIArgs } from './run-cli'; + +export interface Mount { + hostPath: string; + vfsPath: string; +} + +/** + * Parse an array of mount argument strings where the host path and VFS path + * are separated by a colon. + * + * Example: + * parseMountWithDelimiterArguments( [ '/host/path:/vfs/path', '/host/path:/vfs/path' ] ) + * // returns: + * [ + * { hostPath: '/host/path', vfsPath: '/vfs/path' }, + * { hostPath: '/host/path', vfsPath: '/vfs/path' } + * ] + * + * @param mounts - An array of mount argument strings separated by a colon. + * @returns An array of Mount objects. + */ +export function parseMountWithDelimiterArguments(mounts: string[]): Mount[] { + const parsedMounts = []; + for (const mount of mounts) { + const mountParts = mount.split(':'); + if (mountParts.length !== 2) { + throw new Error(`Invalid mount format: ${mount}. + Expected format: /host/path:/vfs/path. + If your path contains a colon, e.g. C:\\myplugin, use the --mount-dir option instead. + Example: --mount-dir C:\\my-plugin /wordpress/wp-content/plugins/my-plugin`); + } + const [hostPath, vfsPath] = mountParts; + if (!existsSync(hostPath)) { + throw new Error(`Host path does not exist: ${hostPath}`); + } + parsedMounts.push({ hostPath, vfsPath }); + } + return parsedMounts; +} + +/** + * Parse an array of mount argument strings where each odd array element is a host path + * and each even element is the VFS path. + * e.g. [ '/host/path', '/vfs/path', '/host/path2', '/vfs/path2' ] + * + * The result will be an array of Mount objects for each host path the + * following element is it's VFS path. + * e.g. [ + * { hostPath: '/host/path', vfsPath: '/vfs/path' }, + * { hostPath: '/host/path2', vfsPath: '/vfs/path2' } + * ] + * + * @param mounts - An array of paths + * @returns An array of Mount objects. + */ +export function parseMountDirArguments(mounts: string[]): Mount[] { + if (mounts.length % 2 !== 0) { + throw new Error('Invalid mount format. Expected: /host/path /vfs/path'); + } + + const parsedMounts = []; + for (let i = 0; i < mounts.length; i += 2) { + const source = mounts[i]; + const vfsPath = mounts[i + 1]; + if (!existsSync(source)) { + throw new Error(`Host path does not exist: ${source}`); + } + parsedMounts.push({ + hostPath: path.resolve(process.cwd(), source), + vfsPath, + }); + } + return parsedMounts; +} + +export async function mountResources(php: PHP, mounts: Mount[]) { + for (const mount of mounts) { + php.mkdir(mount.vfsPath); + await php.mount( + mount.vfsPath, + createNodeFsMountHandler(mount.hostPath) + ); + } +} + +const ACTIVATE_FIRST_THEME_STEP = { + step: 'runPHP', + code: { + filename: 'activate-theme.php', + content: `exists()) { + $themes = wp_get_themes(); + if (count($themes) > 0) { + $themeName = array_keys($themes)[0]; + switch_theme($themeName); + } + } + `, + }, +}; + +/** + * Auto-mounts resolution logic: + */ +export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { + const path = process.cwd(); + + const mount = [...(args.mount || [])]; + const mountBeforeInstall = [...(args['mount-before-install'] || [])]; + + const newArgs = { + ...args, + mount, + 'mount-before-install': mountBeforeInstall, + 'additional-blueprint-steps': [ + ...(args['additional-blueprint-steps'] || []), + ], + }; + + if (isPluginFilename(path)) { + const pluginName = basename(path); + mount.push({ + hostPath: path, + vfsPath: `/wordpress/wp-content/plugins/${pluginName}`, + }); + newArgs['additional-blueprint-steps'].push({ + step: 'activatePlugin', + pluginPath: `/wordpress/wp-content/plugins/${basename(path)}`, + }); + } else if (isThemeDirectory(path)) { + const themeName = basename(path); + mount.push({ + hostPath: path, + vfsPath: `/wordpress/wp-content/themes/${themeName}`, + }); + newArgs['additional-blueprint-steps'].push({ + step: 'activateTheme', + themeDirectoryName: themeName, + }); + } else if (containsWpContentDirectories(path)) { + /** + * Mount each wp-content file and directory individually. + */ + const files = fs.readdirSync(path); + for (const file of files) { + /** + * WordPress already ships with the wp-content/index.php file + * and Playground does not support overriding existing VFS files + * with mounts. + */ + if (file === 'index.php') { + continue; + } + mount.push({ + hostPath: `${path}/${file}`, + vfsPath: `/wordpress/wp-content/${file}`, + }); + } + newArgs['additional-blueprint-steps'].push(ACTIVATE_FIRST_THEME_STEP); + } else if (containsFullWordPressInstallation(path)) { + mountBeforeInstall.push({ hostPath: path, vfsPath: '/wordpress' }); + newArgs.mode = 'apply-to-existing-site'; + newArgs['additional-blueprint-steps'].push(ACTIVATE_FIRST_THEME_STEP); + } else { + /** + * By default, mount the current working directory as the Playground root. + * This allows users to run and PHP or HTML files using the Playground CLI. + */ + mount.push({ hostPath: path, vfsPath: '/wordpress' }); + newArgs.mode = 'mount-only'; + } + + return newArgs as RunCLIArgs; +} + +export function containsFullWordPressInstallation(path: string): boolean { + const files = fs.readdirSync(path); + return ( + files.includes('wp-admin') && + files.includes('wp-includes') && + files.includes('wp-content') + ); +} + +export function containsWpContentDirectories(path: string): boolean { + const files = fs.readdirSync(path); + return ( + files.includes('themes') || + files.includes('plugins') || + files.includes('mu-plugins') || + files.includes('uploads') + ); +} + +export function isThemeDirectory(path: string): boolean { + const files = fs.readdirSync(path); + if (!files.includes('style.css')) { + return false; + } + const styleCssContent = fs.readFileSync(join(path, 'style.css'), 'utf8'); + const themeNameRegex = /^(?:[ \t]*<\?php)?[ \t/*#@]*Theme Name:(.*)$/im; + return !!themeNameRegex.exec(styleCssContent); +} + +export function isPluginFilename(path: string): boolean { + const files = fs.readdirSync(path); + const pluginNameRegex = /^(?:[ \t]*<\?php)?[ \t/*#@]*Plugin Name:(.*)$/im; + const pluginNameMatch = files + .filter((file) => file.endsWith('.php')) + .find((file) => { + const fileContent = fs.readFileSync(join(path, file), 'utf8'); + return !!pluginNameRegex.exec(fileContent); + }); + return !!pluginNameMatch; +} diff --git a/packages/playground/cli/src/resolve-blueprint.ts b/packages/playground/cli/src/resolve-blueprint.ts deleted file mode 100644 index e87b4f5f49..0000000000 --- a/packages/playground/cli/src/resolve-blueprint.ts +++ /dev/null @@ -1,108 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { - ZipFilesystem, - NodeJsFilesystem, - OverlayFilesystem, - InMemoryFilesystem, -} from '@wp-playground/storage'; -import { resolveRemoteBlueprint } from '@wp-playground/blueprints'; -import { ReportableError } from './reportable-error'; - -type ResolveBlueprintOptions = { - sourceString: string | undefined; - blueprintMayReadAdjacentFiles: boolean; -}; - -/** - * Resolves a blueprint from a URL or a local path. - * - * @TODO: Extract the common Blueprint resolution logic between CLI and - * the website into a single, isomorphic resolveBlueprint() function. - * Still retain the CLI-specific bits in the CLI package. - * - * @param sourceString - The source string to resolve the blueprint from. - * @param blueprintMayReadAdjacentFiles - Whether the blueprint may read adjacent files. - * @returns The resolved blueprint. - */ -export async function resolveBlueprint({ - sourceString, - blueprintMayReadAdjacentFiles, -}: ResolveBlueprintOptions) { - if (!sourceString) { - return undefined; - } - - if ( - sourceString.startsWith('http://') || - sourceString.startsWith('https://') - ) { - return await resolveRemoteBlueprint(sourceString); - } - - // If the sourceString does not refer to a remote blueprint, try to - // resolve it from a local filesystem. - - let blueprintPath = path.resolve(process.cwd(), sourceString); - if (!fs.existsSync(blueprintPath)) { - throw new Error(`Blueprint file does not exist: ${blueprintPath}`); - } - - const stat = fs.statSync(blueprintPath); - if (stat.isDirectory()) { - blueprintPath = path.join(blueprintPath, 'blueprint.json'); - } - - if (!stat.isFile() && stat.isSymbolicLink()) { - throw new Error( - `Blueprint path is neither a file nor a directory: ${blueprintPath}` - ); - } - - const extension = path.extname(blueprintPath); - switch (extension) { - case '.zip': - return ZipFilesystem.fromArrayBuffer( - fs.readFileSync(blueprintPath) - ); - case '.json': { - const blueprintText = fs.readFileSync(blueprintPath, 'utf-8'); - try { - JSON.parse(blueprintText); - } catch { - throw new Error( - `Blueprint file at ${blueprintPath} is not a valid JSON file` - ); - } - - const contextPath = path.dirname(blueprintPath); - const nodeJsFilesystem = new NodeJsFilesystem(contextPath); - return new OverlayFilesystem([ - new InMemoryFilesystem({ - 'blueprint.json': blueprintText, - }), - /** - * Wrap the NodeJS filesystem to prevent access to local files - * unless the user explicitly allowed it. - */ - { - read(path) { - if (!blueprintMayReadAdjacentFiles) { - throw new ReportableError( - `Error: Blueprint contained tried to read a local file at path "${path}" (via a resource of type "bundled"). ` + - `Playground restricts access to local resources by default as a security measure. \n\n` + - `You can allow this Blueprint to read files from the same parent directory by explicitly adding the ` + - `--blueprint-may-read-adjacent-files option to your command.` - ); - } - return nodeJsFilesystem.read(path); - }, - }, - ]); - } - default: - throw new Error( - `Unsupported blueprint file extension: ${extension}. Only .zip and .json files are supported.` - ); - } -} diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 0735277053..baf82b5390 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -1,288 +1,326 @@ -import { errorLogPath, logger } from '@php-wasm/logger'; -import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress'; +/** + * @TODO: + * * Mount a stable system tmp or home/.playground-cli directory to store HTTP Cache. + * Flush stale entries periodically. + * * Find a consistent logging interface. Right now we have a logger for some things and + * output.stdout for other things. In the browser, logger prints information to the + * devtools console which is only needed for debugging. The HTML makes for the UI. + * In CLI, the console and the UI are the same thing. Perhaps we actually need to + * separate what we print for UI reasons from what we print for debugging? + */ + +import { logger } from '@php-wasm/logger'; import type { PHPRequest, RemoteAPI, + StreamedPHPResponse, SupportedPHPVersion, } from '@php-wasm/universal'; -import { PHPResponse, consumeAPI, exposeAPI } from '@php-wasm/universal'; -import type { - BlueprintBundle, - BlueprintDeclaration, -} from '@wp-playground/blueprints'; import { - compileBlueprint, - isBlueprintBundle, - runBlueprintSteps, -} from '@wp-playground/blueprints'; + PHPResponse, + SupportedPHPVersions, + consumeAPI, + exposeAPI, +} from '@php-wasm/universal'; +import type { BlueprintDeclaration } from '@wp-playground/blueprints'; import { RecommendedPHPVersion, unzipFile, zipDirectory, } from '@wp-playground/common'; -import fs from 'fs'; +import fs, { existsSync } from 'fs'; import type { Server } from 'http'; import path from 'path'; import { Worker } from 'worker_threads'; +import yargs from 'yargs'; // @ts-ignore -import { resolveWordPressRelease } from '@wp-playground/wordpress'; -import { expandAutoMounts } from './cli-auto-mount'; -import { - CACHE_FOLDER, - cachedDownload, - fetchSqliteIntegration, - readAsFile, -} from './download'; +import { expandAutoMounts } from './mounts'; import { startServer } from './server'; -import type { Mount, PlaygroundCliWorker } from './worker-thread'; +import { printDebugDetails } from '@php-wasm/universal'; +import type { PlaygroundCliWorker } from './worker-thread'; // @ts-ignore import importedWorkerUrlString from './worker-thread?worker&url'; // @ts-ignore import { FileLockManagerForNode } from '@php-wasm/node'; import { LoadBalancer } from './load-balancer'; +import { + parseMountDirArguments, + parseMountWithDelimiterArguments, + type Mount, +} from './mounts'; + /* eslint-disable no-console */ -import { SupportedPHPVersions } from '@php-wasm/universal'; import { cpus } from 'os'; import { jspi } from 'wasm-feature-detect'; -import yargs from 'yargs'; -import { isValidWordPressSlug } from './is-valid-wordpress-slug'; import { - parseMountDirArguments, - parseMountWithDelimiterArguments, -} from './mount'; -import { ReportableError } from './reportable-error'; -import { resolveBlueprint } from './resolve-blueprint'; + type ParsedBlueprintV2Declaration, + parseBlueprintDeclaration, +} from './v2'; -export async function parseOptionsAndRunCLI() { - /** - * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ - * Perhaps the two could be handled by the same code? - */ - const yargsObject = yargs(process.argv.slice(2)) - .usage('Usage: wp-playground [options]') - .positional('command', { - describe: 'Command to run', - choices: ['server', 'run-blueprint', 'build-snapshot'] as const, - demandOption: true, - }) - .option('outfile', { - describe: 'When building, write to this output file.', - type: 'string', - default: 'wordpress.zip', - }) - .option('port', { - describe: 'Port to listen on when serving.', - type: 'number', - default: 9400, - }) - .option('php', { - describe: 'PHP version to use.', - type: 'string', - default: RecommendedPHPVersion, - choices: SupportedPHPVersions, - }) - .option('wp', { - describe: 'WordPress version to use.', - type: 'string', - default: 'latest', - }) - // @TODO: Support read-only mounts, e.g. via WORKERFS, a custom - // ReadOnlyNODEFS, or by copying the files into MEMFS - .option('mount', { - describe: - 'Mount a directory to the PHP runtime (can be used multiple times). Format: /host/path:/vfs/path', - type: 'array', - string: true, - coerce: parseMountWithDelimiterArguments, - }) - .option('mount-before-install', { - describe: - 'Mount a directory to the PHP runtime before WordPress installation (can be used multiple times). Format: /host/path:/vfs/path', - type: 'array', - string: true, - coerce: parseMountWithDelimiterArguments, - }) - .option('mount-dir', { - describe: - 'Mount a directory to the PHP runtime (can be used multiple times). Format: "/host/path" "/vfs/path"', - type: 'array', - nargs: 2, - array: true, - // coerce: parseMountDirArguments, - }) - .option('mount-dir-before-install', { - describe: - 'Mount a directory before WordPress installation (can be used multiple times). Format: "/host/path" "/vfs/path"', - type: 'string', - nargs: 2, - array: true, - coerce: parseMountDirArguments, - }) - .option('login', { - describe: 'Should log the user in', - type: 'boolean', - default: false, - }) - .option('blueprint', { - describe: 'Blueprint to execute.', - type: 'string', - }) - .option('blueprint-may-read-adjacent-files', { - describe: - 'Consent flag: Allow "bundled" resources in a local blueprint to read files in the same directory as the blueprint file.', - type: 'boolean', - default: false, - }) - .option('skip-wordpress-setup', { - describe: - 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', - type: 'boolean', - default: false, - }) - .option('skip-sqlite-setup', { - describe: - 'Skip the SQLite integration plugin setup to allow the WordPress site to use MySQL.', - type: 'boolean', - default: false, - }) - .option('quiet', { - describe: 'Do not output logs and progress messages.', - type: 'boolean', - default: false, - }) - .option('debug', { - describe: - 'Print PHP error log content if an error occurs during Playground boot.', - type: 'boolean', - default: false, - }) - .option('auto-mount', { - describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`, - type: 'boolean', - default: false, - }) - .option('follow-symlinks', { - describe: - 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', - type: 'boolean', - default: false, - }) - .option('experimentalTrace', { - describe: - 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', - type: 'boolean', - default: false, - // Hide this option because we want to replace with a more general log-level flag. - hidden: true, - }) - // TODO: Should we make this a hidden flag? - .option('experimentalMultiWorker', { - describe: - 'Enable experimental multi-worker support which requires JSPI ' + - 'and a /wordpress directory backed by a real filesystem. ' + - 'Pass a positive number to specify the number of workers to use. ' + - 'Otherwise, default to the number of CPUs minus 1.', - type: 'number', - coerce: (value?: number) => value ?? cpus().length - 1, - }) - .showHelpOnFail(false) - .check(async (args) => { - if (args.wp !== undefined && !isValidWordPressSlug(args.wp)) { - try { - // Check if is valid URL - new URL(args.wp); - } catch { - throw new Error( - 'Unrecognized WordPress version. Please use "latest", a URL, or a numeric version such as "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"' - ); - } - } +export interface RunCLIArgs { + 'additional-blueprint-steps'?: any[]; + blueprint?: string | BlueprintDeclaration; + command: 'server' | 'run-blueprint' | 'build-snapshot'; + debug?: boolean; + login?: boolean; + mount?: Mount[]; + 'mount-before-install'?: Mount[]; + outfile?: string; + php: SupportedPHPVersion; + port?: number; + quiet?: boolean; + wp?: string; + 'auto-mount'?: boolean; + // Blueprint CLI options + mode?: string; + 'db-engine'?: string; + 'db-host'?: string; + 'db-user'?: string; + 'db-pass'?: string; + 'db-name'?: string; + 'db-path'?: string; + 'truncate-new-site-directory'?: boolean; + allow?: string; + 'experimental-multi-worker'?: number; + 'experimental-trace'?: boolean; +} - if (args.experimentalMultiWorker !== undefined) { - if (args.experimentalMultiWorker <= 1) { - throw new Error( - 'The --experimentalMultiWorker flag must be a positive integer greater than 1.' - ); - } +export async function parseOptionsAndRunCLI() { + let cliArgs: RunCLIArgs | undefined = undefined; + try { + /** + * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ + * Perhaps the two could be handled by the same code? + */ + const yargsObject = yargs(process.argv.slice(2)) + .usage('Usage: wp-playground [options]') + .positional('command', { + describe: 'Command to run', + choices: ['server', 'run-blueprint', 'build-snapshot'] as const, + demandOption: true, + }) + .option('outfile', { + describe: 'When building, write to this output file.', + type: 'string', + default: 'wordpress.zip', + }) + .option('port', { + describe: 'Port to listen on when serving.', + type: 'number', + default: 9400, + }) + + // Blueprints v2 CLI options + .option('php', { + describe: + 'PHP version to use. If Blueprint is provided, this option overrides the PHP version specified in the Blueprint.', + type: 'string', + choices: SupportedPHPVersions, + }) + + // Modifies the Blueprint: + .option('wp', { + describe: + 'WordPress version to use. If Blueprint is provided, this option overrides the WordPress version specified in the Blueprint.', + type: 'string', + default: 'latest', + hidden: true, + }) + .option('login', { + describe: + 'Should log the user in. If Blueprint is provided, this option overrides the login specified in the Blueprint.', + type: 'boolean', + default: false, + hidden: true, + }) + + // @TODO: Support read-only mounts, e.g. via WORKERFS, a custom + // ReadOnlyNODEFS, or by copying the files into MEMFS + .option('mount', { + describe: + 'Mount a directory to the PHP runtime. You can provide --mount multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + coerce: parseMountWithDelimiterArguments, + }) + .option('mount-before-install', { + describe: + 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + coerce: parseMountWithDelimiterArguments, + }) + .option('mount-dir', { + describe: + 'Mount a directory to the PHP runtime. You can provide --mount-dir multiple times. Format: "/host/path" "/vfs/path"', + type: 'array', + nargs: 2, + array: true, + coerce: parseMountDirArguments, + }) + .option('mount-dir-before-install', { + describe: + 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: "/host/path" "/vfs/path"', + type: 'string', + nargs: 2, + array: true, + coerce: parseMountDirArguments, + }) + .option('blueprint', { + describe: 'Blueprint to execute.', + type: 'string', + }) + .option('quiet', { + describe: 'Do not output logs and progress messages.', + type: 'boolean', + default: false, + }) + .option('debug', { + describe: + 'Print PHP error log content if an error occurs during Playground boot.', + type: 'boolean', + default: false, + }) + .option('auto-mount', { + describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`, + type: 'boolean', + default: false, + }) + // Blueprint CLI options + .option('mode', { + describe: 'Execution mode', + type: 'string', + default: 'create-new-site', + choices: [ + 'create-new-site', + 'apply-to-existing-site', + 'mount-only', + ], + }) + .option('db-engine', { + describe: 'Database engine', + type: 'string', + default: 'sqlite', + choices: ['mysql', 'sqlite'], + }) + .option('db-host', { + describe: 'MySQL host', + type: 'string', + }) + .option('db-user', { + describe: 'MySQL user', + type: 'string', + }) + .option('db-pass', { + describe: 'MySQL password', + type: 'string', + }) + .option('db-name', { + describe: 'MySQL database', + type: 'string', + }) + .option('db-path', { + describe: 'SQLite file path', + type: 'string', + }) + .option('truncate-new-site-directory', { + describe: + 'Delete target directory if it exists before execution', + type: 'boolean', + }) + .option('allow', { + describe: 'Allowed permissions (comma-separated)', + type: 'string', + coerce: (value) => value.split(','), + choices: ['bundled-files', 'follow-symlinks'], + }) + .option('follow-symlinks', { + describe: + 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', + type: 'boolean', + default: false, + }) + .option('experimental-trace', { + describe: + 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', + type: 'boolean', + default: false, + // Hide this option because we want to replace with a more general log-level flag. + hidden: true, + }) + // TODO: Should we make this a hidden flag? + .option('experimental-multi-worker', { + describe: + 'Enable experimental multi-worker support which requires JSPI ' + + 'and a /wordpress directory backed by a real filesystem. ' + + 'Pass a positive number to specify the number of workers to use. ' + + 'Otherwise, default to the number of CPUs minus 1.', + type: 'number', + coerce: (value?: number) => value ?? cpus().length - 1, + }) + .showHelpOnFail(false) + .check(async (args) => { + if (args['experimental-multi-worker'] !== undefined) { + if (args['experimental-multi-worker'] <= 1) { + const message = + 'The --experimentalMultiWorker flag must be a positive integer greater than 1.'; + console.error(message); + throw new Error(message); + } - if (!(await jspi())) { - throw new Error( - 'JavaScript Promise Integration (JSPI) is not enabled. Please enable JSPI in your JavaScript runtime before using the --experimentalMultiWorker flag.' - ); - } + if (!(await jspi())) { + const message = + 'JavaScript Promise Integration (JSPI) is not enabled. Please enable JSPI in your JavaScript runtime before using the --experimentalMultiWorker flag. In Node.js, you can use the --experimental-wasm-jspi flag.'; + console.error(message); + throw new Error(message); + } - const isMountingWordPressDir = (mount: Mount) => - mount.vfsPath === '/wordpress'; - if ( - !args.mount?.some(isMountingWordPressDir) && - !(args['mountBeforeInstall'] as any)?.some( - isMountingWordPressDir - ) - ) { - throw new Error( - 'Please mount a real filesystem directory as the /wordpress directory before using the --experimentalMultiWorker flag.' - ); + const isMountingWordPressDir = (mount: Mount) => + mount.vfsPath === '/wordpress'; + if ( + !args.mount?.some(isMountingWordPressDir) && + !(args['mount-before-install'] as any)?.some( + isMountingWordPressDir + ) + ) { + const message = + 'Please mount a real filesystem directory as the /wordpress directory before using the --experimentalMultiWorker flag.'; + console.error(message); + throw new Error(message); + } } - } - return true; - }); + return true; + }); - yargsObject.wrap(yargsObject.terminalWidth()); - const args = await yargsObject.argv; + yargsObject.wrap(yargsObject.terminalWidth()); + const args = await yargsObject.argv; - const command = args._[0] as string; + const command = args._[0] as string; - if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { - yargsObject.showHelp(); - process.exit(1); - } - - const cliArgs = { - ...args, - command, - blueprint: await resolveBlueprint({ - sourceString: args.blueprint, - blueprintMayReadAdjacentFiles: args.blueprintMayReadAdjacentFiles, - }), - mount: [...(args.mount || []), ...(args.mountDir || [])], - mountBeforeInstall: [ - ...(args.mountBeforeInstall || []), - ...(args.mountDirBeforeInstall || []), - ], - } as RunCLIArgs; + if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { + yargsObject.showHelp(); + process.exit(1); + } - try { - return runCLI(cliArgs); + cliArgs = { + ...args, + command, + mount: [...(args.mount || []), ...(args.mountDir || [])], + 'mount-before-install': [ + ...(args['mount-before-install'] || []), + ...(args['mount-dir-before-install'] || []), + ], + } as RunCLIArgs; + + return await runCLI(cliArgs); } catch (e) { - const reportableCause = ReportableError.getReportableCause(e); - if (reportableCause) { - console.log(''); - console.log(reportableCause.message); - process.exit(1); - } else { - throw e; + if (cliArgs?.debug) { + await printDebugDetails(e, (e as any)?.streamedResponse); } - } -} -export interface RunCLIArgs { - blueprint?: BlueprintDeclaration | BlueprintBundle; - command: 'server' | 'run-blueprint' | 'build-snapshot'; - debug?: boolean; - login?: boolean; - mount?: Mount[]; - mountBeforeInstall?: Mount[]; - outfile?: string; - php?: SupportedPHPVersion; - port?: number; - quiet?: boolean; - skipWordPressSetup?: boolean; - skipSqliteSetup?: boolean; - wp?: string; - autoMount?: boolean; - followSymlinks?: boolean; - experimentalMultiWorker?: number; - experimentalTrace?: boolean; + // If we did not expect this error, print **all** the debug details we can get. + throw e; + } } export interface RunCLIServer extends AsyncDisposable { @@ -292,272 +330,123 @@ export interface RunCLIServer extends AsyncDisposable { } export async function runCLI(args: RunCLIArgs): Promise { - let loadBalancer: LoadBalancer; - let playground: RemoteAPI; - - const playgroundsToCleanUp: { - playground: RemoteAPI; - worker: Worker; - }[] = []; - - /** - * Expand auto-mounts to include the necessary mounts and steps - * when running in auto-mount mode. - */ - if (args.autoMount) { - args = expandAutoMounts(args); - } + let streamedResponse: StreamedPHPResponse | undefined; + let initialWorker: Worker; - /** - * TODO: This exact feature will be provided in the PHP Blueprints library. - * Let's use it when it ships. Let's also use it in the web Playground - * app. - */ - async function zipSite(outfile: string) { - await playground.run({ - code: `open('/tmp/build.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)) { - throw new Exception('Failed to create ZIP'); - } - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator('/wordpress') - ); - foreach ($files as $file) { - echo $file . PHP_EOL; - if (!$file->isFile()) { - continue; - } - $zip->addFile($file->getPathname(), $file->getPathname()); - } - $zip->close(); + try { + let loadBalancer: LoadBalancer; + let playground: RemoteAPI; - `, - }); - const zip = await playground.readFileAsBuffer('/tmp/build.zip'); - fs.writeFileSync(outfile, zip); - } + const playgroundsToCleanUp: { + playground: RemoteAPI; + worker: Worker; + }[] = []; - async function compileInputBlueprint() { /** - * @TODO This looks similar to the resolveBlueprint() call in the website package: - * https://github.com/WordPress/wordpress-playground/blob/ce586059e5885d185376184fdd2f52335cca32b0/packages/playground/website/src/main.tsx#L41 - * - * Also the Blueprint Builder tool does something similar. - * Perhaps all these cases could be handled by the same function? + * Expand auto-mounts to include the necessary mounts and steps + * when running in auto-mount mode. */ - const blueprint: BlueprintDeclaration | BlueprintBundle = - isBlueprintBundle(args.blueprint) - ? args.blueprint - : { - login: args.login, - ...args.blueprint, - preferredVersions: { - php: - args.php ?? - args?.blueprint?.preferredVersions?.php ?? - RecommendedPHPVersion, - wp: - args.wp ?? - args?.blueprint?.preferredVersions?.wp ?? - 'latest', - ...(args.blueprint?.preferredVersions || {}), - }, - }; - - const tracker = new ProgressTracker(); - let lastCaption = ''; - let progressReached100 = false; - tracker.addEventListener('progress', (e: any) => { - if (progressReached100) { - return; - } - progressReached100 = e.detail.progress === 100; - - // Use floor() so we don't report 100% until truly there. - const progressInteger = Math.floor(e.detail.progress); - lastCaption = - e.detail.caption || lastCaption || 'Running the Blueprint'; - const message = `${lastCaption.trim()} – ${progressInteger}%`; - if (!args.quiet) { - writeProgressUpdate( - process.stdout, - message, - progressReached100 - ); - } - }); - return await compileBlueprint(blueprint as BlueprintDeclaration, { - progress: tracker, - }); - } - - let lastProgressMessage = ''; - function writeProgressUpdate( - writeStream: NodeJS.WriteStream, - message: string, - finalUpdate: boolean - ) { - if (message === lastProgressMessage) { - // Avoid repeating the same message - return; + if (args['auto-mount']) { + args = expandAutoMounts(args); } - lastProgressMessage = message; - if (writeStream.isTTY) { - // Overwrite previous progress updates in-place for a quieter UX. - writeStream.cursorTo(0); - writeStream.write(message); - writeStream.clearLine(1); + const phpVersion = args.php || (await inferPHP(args.blueprint)); + let wordPressReady = false; + let isFirstRequest = true; - if (finalUpdate) { - writeStream.write('\n'); - } - } else { - // Fall back to writing one line per progress update - writeStream.write(`${message}\n`); + /** + * Spawns a new Worker Thread. + * + * @param workerUrl The absolute URL of the worker script. + * @returns The spawned Worker Thread. + */ + async function spawnPHPWorkerThread( + workerUrl: URL, + onExit: (code: number) => void + ) { + const worker = new Worker(workerUrl); + + return new Promise((resolve, reject) => { + function onMessage(event: string) { + // Let the worker confirm it has initialized. + // We could use the 'online' event to detect start of JS execution, + // but that would miss initialization errors. + if (event === 'worker-script-initialized') { + resolve(worker); + worker.off('message', onMessage); + } + } + function onError(e: Error) { + const error = new Error( + `Worker failed to load at ${workerUrl}. ${ + e.message ? `Original error: ${e.message}` : '' + }` + ); + (error as any).filename = workerUrl; + reject(error); + worker.off('error', onError); + } + worker.on('message', onMessage); + worker.on('error', onError); + worker.on('exit', onExit); + }); } - } - /** - * Spawns a new Worker Thread. - * - * @param workerUrl The absolute URL of the worker script. - * @returns The spawned Worker Thread. - */ - async function spawnPHPWorkerThread(workerUrl: URL) { - const worker = new Worker(workerUrl); - - return new Promise((resolve, reject) => { - function onMessage(event: string) { - // Let the worker confirm it has initialized. - // We could use the 'online' event to detect start of JS execution, - // but that would miss initialization errors. - if (event === 'worker-script-initialized') { - resolve(worker); - worker.off('message', onMessage); - } - } - function onError(e: Error) { - const error = new Error( - `Worker failed to load at ${workerUrl}. ${ - e.message ? `Original error: ${e.message}` : '' - }` + function spawnWorkerThreads(count: number): Promise { + const moduleWorkerUrl = new URL( + importedWorkerUrlString, + import.meta.url + ); + + const promises = []; + for (let i = 0; i < count; i++) { + promises.push( + spawnPHPWorkerThread(moduleWorkerUrl, (code) => { + if (code !== 0) { + process.stderr.write( + `Worker ${i} exited with code ${code}\n` + ); + // If the primary worker crashes, exit the entire process. + if (i === 0) { + process.exit(1); + } + } + }) ); - (error as any).filename = workerUrl; - reject(error); - worker.off('error', onError); } - worker.on('message', onMessage); - worker.on('error', onError); - }); - } - - function spawnWorkerThreads(count: number): Promise { - const moduleWorkerUrl = new URL( - importedWorkerUrlString, - import.meta.url - ); + return Promise.all(promises); + } - const promises = []; - for (let i = 0; i < count; i++) { - promises.push(spawnPHPWorkerThread(moduleWorkerUrl)); + if (args.quiet) { + // @ts-ignore + logger.handlers = []; } - return Promise.all(promises); - } - if (args.quiet) { - // @ts-ignore - logger.handlers = []; - } + // Declare file lock manager outside scope of startServer + // so we can look at it when debugging request handling. + const fileLockManager = new FileLockManagerForNode(); - const compiledBlueprint = await compileInputBlueprint(); - - // Declare file lock manager outside scope of startServer - // so we can look at it when debugging request handling. - const fileLockManager = new FileLockManagerForNode(); - - let wordPressReady = false; - - logger.log('Starting a PHP server...'); - - return startServer({ - port: args['port'] as number, - onBind: async (server: Server, port: number): Promise => { - const absoluteUrl = `http://127.0.0.1:${port}`; - - // Kick off worker threads now to save time later. - // There is no need to wait for other async processes to complete. - const totalWorkerCount = args.experimentalMultiWorker ?? 1; - const promisedWorkers = spawnWorkerThreads(totalWorkerCount); - - logger.log(`Setting up WordPress ${args.wp}`); - let wpDetails: any = undefined; - // @TODO: Rename to FetchProgressMonitor. There's nothing Emscripten - // about that class anymore. - const monitor = new EmscriptenDownloadMonitor(); - if (!args.skipWordPressSetup) { - let progressReached100 = false; - monitor.addEventListener('progress', (( - e: CustomEvent - ) => { - if (progressReached100) { - return; - } + logger.log('Starting a PHP server...'); - // @TODO Every progress bar will want percentages. The - // download monitor should just provide that. - const { loaded, total } = e.detail; - // Use floor() so we don't report 100% until truly there. - const percentProgress = Math.floor( - Math.min(100, (100 * loaded) / total) - ); - progressReached100 = percentProgress === 100; + return await startServer({ + port: args['port'] as number, + onBind: async ( + server: Server, + port: number + ): Promise => { + const siteUrl = `http://127.0.0.1:${port}`; - if (!args.quiet) { - writeProgressUpdate( - process.stdout, - `Downloading WordPress ${percentProgress}%...`, - progressReached100 - ); - } - }) as any); + logger.log(`Setting up WordPress ${args.wp}`); - wpDetails = await resolveWordPressRelease(args.wp); - logger.log( - `Resolved WordPress release URL: ${wpDetails?.releaseUrl}` - ); - } + // Kick off worker threads now to save time later. + // There is no need to wait for other async processes to complete. + const totalWorkerCount = args['experimental-multi-worker'] ?? 1; + const promisedWorkers = spawnWorkerThreads(totalWorkerCount); - const preinstalledWpContentPath = - wpDetails && - path.join( - CACHE_FOLDER, - `prebuilt-wp-content-for-wp-${wpDetails.version}.zip` - ); - const wordPressZip = !wpDetails - ? undefined - : fs.existsSync(preinstalledWpContentPath) - ? readAsFile(preinstalledWpContentPath) - : await cachedDownload( - wpDetails.releaseUrl, - `${wpDetails.version}.zip`, - monitor - ); - - logger.log(`Fetching SQLite integration plugin...`); - const sqliteIntegrationPluginZip = args.skipSqliteSetup - ? undefined - : await fetchSqliteIntegration(monitor); - - const followSymlinks = args.followSymlinks === true; - const trace = args.experimentalTrace === true; - try { - const mountsBeforeWpInstall = args.mountBeforeInstall || []; - const mountsAfterWpInstall = args.mount || []; - - const [initialWorker, ...additionalWorkers] = - await promisedWorkers; + const trace = args['experimental-trace'] === true; + const workers = await promisedWorkers; + initialWorker = workers[0]; + const additionalWorkers = workers.slice(1); playground = consumeAPI(initialWorker); playgroundsToCleanUp.push({ @@ -579,51 +468,45 @@ export async function runCLI(args: RunCLIArgs): Promise { Number.MAX_SAFE_INTEGER / totalWorkerCount ); - await playground.boot({ - phpVersion: compiledBlueprint.versions.php, - wpVersion: compiledBlueprint.versions.wp, - absoluteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - wordPressZip: - wordPressZip && (await wordPressZip!.arrayBuffer()), - sqliteIntegrationPluginZip: - await sqliteIntegrationPluginZip!.arrayBuffer(), - firstProcessId: 0, - processIdSpaceLength, - followSymlinks, - trace, - }); + try { + await playground.bootAsPrimaryWorker({ + ...args, + php: phpVersion, + siteUrl, + firstProcessId: 0, + processIdSpaceLength, + trace, + }); + } catch (e) { + await initialWorker.terminate(); + throw e; + } - if ( - wpDetails && - !args.mountBeforeInstall && - !fs.existsSync(preinstalledWpContentPath) - ) { - logger.log( - `Caching preinstalled WordPress for the next boot...` + if (args.login) { + // @TODO: Do we need this in all the workers? Or just in the primary one? + // Are we sharing constants between workers? + await playground.defineConstant( + 'PLAYGROUND_AUTO_LOGIN_AS_USER', + 'admin' ); - fs.writeFileSync( - preinstalledWpContentPath, - await zipDirectory(playground, '/wordpress') - ); - logger.log(`Cached!`); } loadBalancer = new LoadBalancer(playground); await playground.isReady(); wordPressReady = true; - logger.log(`Booted!`); - if (compiledBlueprint) { - logger.log(`Running the Blueprint...`); - await runBlueprintSteps(compiledBlueprint, playground); - logger.log(`Finished running the blueprint`); - } + // Add a newline after the progress bar to avoid the next log message + // from being printed on the same line. + logger.log(''); + logger.log(`Booted!`); if (args.command === 'build-snapshot') { - await zipSite(args.outfile as string); + await zipDirectory( + playground, + '/wordpress', + args.outfile as string + ); logger.log(`WordPress exported to ${args.outfile}`); process.exit(0); } else if (args.command === 'run-blueprint') { @@ -632,8 +515,8 @@ export async function runCLI(args: RunCLIArgs): Promise { } if ( - args.experimentalMultiWorker && - args.experimentalMultiWorker > 1 + args['experimental-multi-worker'] && + args['experimental-multi-worker'] > 1 ) { logger.log(`Preparing additional workers...`); @@ -642,7 +525,7 @@ export async function runCLI(args: RunCLIArgs): Promise { const internalZip = await zipDirectory( playground, '/internal' - ); + )!; // Boot additional workers const initialWorkerProcessIdSpace = processIdSpaceLength; @@ -662,22 +545,12 @@ export async function runCLI(args: RunCLIArgs): Promise { initialWorkerProcessIdSpace + index * processIdSpaceLength; - await additionalPlayground.boot({ - phpVersion: compiledBlueprint.versions.php, - absoluteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - // Skip WordPress zip because we share the /wordpress directory - // populated by the initial worker. - wordPressZip: undefined, - // Skip SQLite integration plugin for now because we - // will copy it from primary's `/internal` directory. - sqliteIntegrationPluginZip: undefined, - dataSqlPath: - '/wordpress/wp-content/database/.ht.sqlite', + await additionalPlayground.bootAsSecondaryWorker({ + ...args, + php: phpVersion, + siteUrl, firstProcessId, processIdSpaceLength, - followSymlinks, trace, }); await additionalPlayground.isReady(); @@ -685,7 +558,7 @@ export async function runCLI(args: RunCLIArgs): Promise { // Replicate the Blueprint-initialized /internal directory await additionalPlayground.writeFile( '/tmp/internal.zip', - internalZip + internalZip! ); await unzipFile( additionalPlayground, @@ -703,7 +576,7 @@ export async function runCLI(args: RunCLIArgs): Promise { logger.log(`Ready!`); } - logger.log(`WordPress is running on ${absoluteUrl}`); + logger.log(`WordPress is running on ${siteUrl}`); return { playground, @@ -720,22 +593,290 @@ export async function runCLI(args: RunCLIArgs): Promise { await new Promise((resolve) => server.close(resolve)); }, }; - } catch (error) { - if (!args.debug) { - throw error; + }, + async handleRequest(request: PHPRequest) { + if (!wordPressReady) { + return PHPResponse.forHttpCode( + 502, + 'WordPress is not ready yet' + ); + } + + // Clear the playground_auto_login_already_happened cookie on the first request. + // Otherwise the first Playground CLI server started on the machine will set it, + // all the subsequent runs will get the stale cookie, and the auto-login will + // assume they don't have to auto-login again. + if (isFirstRequest) { + isFirstRequest = false; + if ( + request.headers?.['cookie']?.includes( + 'playground_auto_login_already_happened' + ) + ) { + return new PHPResponse( + 302, + { + 'Set-Cookie': [ + 'playground_auto_login_already_happened=1; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/', + ], + 'Content-Type': ['text/plain'], + 'Content-Length': ['0'], + Location: ['/'], + }, + new Uint8Array() + ); + } + } + + return await loadBalancer.handleRequest(request); + }, + }); + } catch (e) { + if (e) { + (e as any).streamedResponse = streamedResponse; + } + // If we did not expect this error, print **all** the debug details we can get. + throw e; + } +} + +/** + * Infer the PHP version from the Blueprint declaration when the user + * didn't explicitly provide --php. This needs to happen before we boot + * the request handler so that we download / load the correct runtime. + * + * Ideally, we wouldn't need to reason about the Blueprint structure inside + * TypeScript at all. We already have great PHP libraries for handling all that. + * Unfortunately, we did not boot PHP yet. Even worse, we don't know which PHP + * version we need to load yet. + * + * The code below duplicates the data resolution and Blueprint parsing logic + * from the PHP Blueprint runner. We don't need it that much in CLI, since we + * could just load any PHP version to parse the Blueprint and then load the + * correct runtime. However, we will need it in the browser where downloading + * PHP runtimes is expensive. We might as well implement it once and reuse it + * in both places. + * + * ## Limitations + * + * * It can only handle JSON blueprints. Bundles (ZIP, git, etc.) are unsupported. + * The user must provide an explicit `--php=` version when using a bundle. + * + * @param blueprint The Blueprint declaration. + * @returns The PHP version to use. + */ +async function inferPHP(blueprint: string | BlueprintDeclaration | undefined) { + try { + if (!blueprint) { + return RecommendedPHPVersion; + } + /** + * Infer the PHP version from the Blueprint declaration when the user + * didn't explicitly provide --php. This needs to happen before we boot + * the request handler so that we download / load the correct runtime. + */ + const blueprintObject = await resolveBlueprintObject( + parseBlueprintDeclaration(blueprint) + ); + if (!blueprintObject || typeof blueprintObject !== 'object') { + throw new Error('Blueprint is not a valid object'); + } + + let requestedPhp: any | string | undefined = undefined; + /** + * We must, unfortunately, account for all possible versions of the Blueprint + * schema in here. The transpilation to the latest version only happens in the + * PHP code. + */ + requestedPhp = + blueprintObject.phpVersion ?? + blueprintObject.preferredVersions?.php ?? + RecommendedPHPVersion; + + if ( + blueprintObject.phpVersion && + typeof blueprintObject.phpVersion === 'object' + ) { + return ( + blueprintObject.phpVersion.recommended || + blueprintObject.phpVersion.max || + blueprintObject.phpVersion.min + ); + } else if (typeof requestedPhp === 'string') { + return requestedPhp as SupportedPHPVersion; + } else { + throw new Error('phpVersion is not a valid object or string'); + } + } catch (e) { + if (e instanceof NonJsonBlueprintError) { + process.stderr.write( + `Could not determine the PHP version from the Blueprint. ` + + `This usually happens if your Blueprint is not a plain JSON file ` + + `(for example, if it's a ZIP, git repo, or another bundle format). ` + + `Automatic PHP version detection only works for JSON blueprints. ` + + `To continue, please specify the PHP version explicitly using the --php option (e.g. --php=8.2).` + ); + throw e; + } else if (e instanceof BlueprintReferenceError) { + process.stderr.write( + `Failed to load Blueprint: ${e.message}. ` + + `Please check that the Blueprint path or URL is correct.` + ); + throw e; + } else if (e instanceof BlueprintParseError) { + process.stderr.write( + `Blueprint contains invalid JSON: ${e.parseError}. ` + + `Please check the Blueprint syntax and try again.` + ); + throw e; + } + + // Generic inference failure + throw new Error( + `Failed to infer PHP version from Blueprint: ${ + e instanceof Error ? e.message : 'Unknown error' + }. ` + + `Please specify the PHP version explicitly using the --php option.` + ); + } +} + +async function resolveBlueprintObject( + declaration: ParsedBlueprintV2Declaration +): Promise { + if (declaration.type === 'inline-file') { + try { + return JSON.parse(declaration.contents); + } catch (e) { + throw new BlueprintParseError( + `Failed to parse inline Blueprint JSON`, + e instanceof Error ? e.message : 'Unknown JSON parse error' + ); + } + } + if (declaration.type === 'file-reference') { + const filePath = declaration.reference; + const isUrl = + filePath.startsWith('http://') || filePath.startsWith('https://'); + let contents: string; + + try { + if (isUrl) { + // @TODO: Respect HTTP cache in CLI. + const response = await fetch(filePath); + if (!response.ok) { + throw new BlueprintReferenceError( + `Failed to fetch Blueprint from URL (HTTP ${response.status})`, + filePath, + response.status + ); + } + contents = await response.text(); + } else { + const resolvedPath = filePath.startsWith('/') + ? filePath + : path.resolve(process.cwd(), filePath); + + if (!existsSync(resolvedPath)) { + throw new BlueprintReferenceError( + `Blueprint file not found`, + resolvedPath + ); } - const phpLogs = await playground.readFileAsText(errorLogPath); - throw new Error(phpLogs, { cause: error }); + + try { + contents = fs.readFileSync(resolvedPath, 'utf8'); + } catch (e) { + if ((e as any).code === 'ENOENT') { + throw new BlueprintReferenceError( + `Blueprint file not found`, + resolvedPath + ); + } + throw new BlueprintReferenceError( + `Failed to read Blueprint file: ${ + (e as any).message || 'Unknown error' + }`, + resolvedPath + ); + } + } + } catch (e) { + // Re-throw our custom errors + if (e instanceof BlueprintReferenceError) { + throw e; } - }, - async handleRequest(request: PHPRequest) { - if (!wordPressReady) { - return PHPResponse.forHttpCode( - 502, - 'WordPress is not ready yet' + // Handle other network/fetch errors + throw new BlueprintReferenceError( + `Failed to load Blueprint: ${ + e instanceof Error ? e.message : 'Unknown error' + }`, + filePath + ); + } + + try { + return JSON.parse(contents); + } catch (e) { + // Check if this looks like a non-JSON file (ZIP, binary, etc.) + if ( + contents.startsWith('PK') || + contents.includes('\x00') || + !contents.trim().startsWith('{') + ) { + const detectedType = contents.startsWith('PK') + ? 'ZIP archive' + : contents.includes('\x00') + ? 'binary file' + : 'non-JSON text file'; + throw new NonJsonBlueprintError( + `Blueprint appears to be a ${detectedType}, not a JSON file`, + detectedType ); } - return await loadBalancer.handleRequest(request); - }, - }); + throw new BlueprintParseError( + `Failed to parse Blueprint JSON from ${isUrl ? 'URL' : 'file'}`, + e instanceof Error ? e.message : 'Unknown JSON parse error' + ); + } + } + throw new NonJsonBlueprintError( + `Unknown blueprint declaration type`, + 'unknown' + ); +} + +/** + * Custom error classes for blueprint resolution failures + */ +class NonJsonBlueprintError extends Error { + public readonly blueprintType: string; + + constructor(message: string, blueprintType: string) { + super(message); + this.name = 'NonJsonBlueprintError'; + this.blueprintType = blueprintType; + } +} + +class BlueprintReferenceError extends Error { + public readonly reference: string; + public readonly statusCode?: number; + + constructor(message: string, reference: string, statusCode?: number) { + super(message); + this.name = 'BlueprintReferenceError'; + this.reference = reference; + this.statusCode = statusCode; + } +} + +class BlueprintParseError extends Error { + public readonly parseError: string; + + constructor(message: string, parseError: string) { + super(message); + this.name = 'BlueprintParseError'; + this.parseError = parseError; + } } diff --git a/packages/playground/cli/src/test/cli-run.spec.ts b/packages/playground/cli/src/test/cli-run.spec.ts index ea1aba53c5..127f46e5bd 100644 --- a/packages/playground/cli/src/test/cli-run.spec.ts +++ b/packages/playground/cli/src/test/cli-run.spec.ts @@ -1,15 +1,13 @@ +import { MinifiedWordPressVersionsList } from '@wp-playground/wordpress-builds'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readdirSync } from 'node:fs'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import { runCLI } from '../run-cli'; -import type { RunCLIServer } from '../run-cli'; import type { MockInstance } from 'vitest'; import { vi } from 'vitest'; -import { mkdtemp, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { promisify } from 'node:util'; -import { exec } from 'node:child_process'; -import { readdirSync } from 'node:fs'; -import { createHash } from 'node:crypto'; -import { MinifiedWordPressVersionsList } from '@wp-playground/wordpress-builds'; +import type { RunCLIServer } from '../run-cli'; +import { runCLI } from '../run-cli'; // TODO: Fix or rework these tests because it is difficult to run them now that // runCLI() launches a Worker. @@ -26,6 +24,7 @@ describe.skip('cli-run', () => { cliServer = await runCLI({ command: 'server', php: '8.0', + quiet: true, }); await cliServer.playground.writeFile( '/wordpress/version.php', @@ -45,6 +44,7 @@ describe.skip('cli-run', () => { MinifiedWordPressVersionsList.length - 1 ]; cliServer = await runCLI({ + php: '8.0', command: 'server', wp: oldestSupportedVersion, }); @@ -65,6 +65,7 @@ describe.skip('cli-run', () => { test('should run blueprint', async () => { cliServer = await runCLI({ + php: '8.0', command: 'server', blueprint: { steps: [ @@ -76,6 +77,7 @@ describe.skip('cli-run', () => { }, ], }, + quiet: true, }); const response = await cliServer.playground.request({ url: '/', @@ -114,8 +116,10 @@ describe.skip('cli-run', () => { path.join(__dirname, 'mount-examples', 'plugin') ); cliServer = await runCLI({ + php: '8.0', command: 'server', - autoMount: true, + 'auto-mount': true, + quiet: true, }); const phpResponse = await cliServer.playground.run({ code: ` { method: 'GET', }); expect(response.httpStatusCode).toBe(200); - expect(response.text).toContain( - 'My WordPress Website' - ); + expect(response.text).toContain('WordPress'); }); test(`should run a theme project using --auto-mount`, async () => { vi.spyOn(process, 'cwd').mockReturnValue( path.join(__dirname, 'mount-examples', 'theme') ); cliServer = await runCLI({ + php: '8.0', command: 'server', - autoMount: true, + 'auto-mount': true, + quiet: true, }); expect(await getActiveTheme()).toBe('Yolo Theme'); @@ -151,9 +155,7 @@ describe.skip('cli-run', () => { method: 'GET', }); expect(response.httpStatusCode).toBe(200); - expect(response.text).toContain( - 'My WordPress Website' - ); + expect(response.text).toContain('WordPress'); }); test(`should run a wp-content project using --auto-mount`, async () => { @@ -161,26 +163,32 @@ describe.skip('cli-run', () => { path.join(__dirname, 'mount-examples', 'wp-content') ); cliServer = await runCLI({ + php: '8.0', command: 'server', - autoMount: true, + 'auto-mount': true, + // quiet: true, }); const response = await cliServer.playground.request({ url: '/wp-login.php', method: 'GET', }); - expect(response.httpStatusCode).toBe(200); - }); + expect(response.httpStatusCode).toBe(500); + expect(response.text).toContain( + 'Error establishing a database connection' + ); + }, 20000); test('should run a static html project using --auto-mount', async () => { vi.spyOn(process, 'cwd').mockReturnValue( path.join(__dirname, 'mount-examples', 'static-html') ); cliServer = await runCLI({ + php: '8.0', command: 'server', - autoMount: true, + 'auto-mount': true, }); const response = await cliServer.playground.request({ - url: '/', + url: '/index.html', method: 'GET', }); expect(response.httpStatusCode).toBe(200); @@ -192,8 +200,10 @@ describe.skip('cli-run', () => { path.join(__dirname, 'mount-examples', 'php') ); cliServer = await runCLI({ + php: '8.0', command: 'server', - autoMount: true, + 'auto-mount': true, + quiet: true, }); const response = await cliServer.playground.request({ url: '/', @@ -207,34 +217,44 @@ describe.skip('cli-run', () => { const tmpDir = await mkdtemp( path.join(tmpdir(), 'playground-test-') ); - vi.spyOn(process, 'cwd').mockReturnValue( - path.join(tmpDir, 'wordpress') - ); + const wordpressDir = path.join(tmpDir, 'wordpress'); + mkdirSync(wordpressDir); + vi.spyOn(process, 'cwd').mockReturnValue(wordpressDir); - const zip = await fetch('https://wordpress.org/latest.zip'); - const zipPath = path.join(tmpDir, 'wp.zip'); - await writeFile(zipPath, new Uint8Array(await zip.arrayBuffer())); - await promisify(exec)(`unzip "${zipPath}" -d "${tmpDir}"`); - - const checksum = await getDirectoryChecksum(tmpDir); + cliServer = await runCLI({ + port: 58954, + php: '8.0', + command: 'server', + // quiet: true, + 'mount-before-install': [ + { + hostPath: wordpressDir, + vfsPath: '/wordpress', + }, + ], + }); + cliServer.server.close(); + const checksum = await getDirectoryChecksum(wordpressDir); cliServer = await runCLI({ + port: 58954, + php: '8.0', command: 'server', - autoMount: true, + 'auto-mount': true, + // quiet: true, }); + const response = await cliServer.playground.request({ url: '/', method: 'GET', }); expect(response.httpStatusCode).toBe(200); - expect(response.text).toContain( - 'My WordPress Website' - ); + expect(response.text).toContain('WordPress'); /** * Playground should not modify the mounted directory. */ - expect(await getDirectoryChecksum(tmpDir)).toBe(checksum); + expect(await getDirectoryChecksum(wordpressDir)).toBe(checksum); }); }); }); diff --git a/packages/playground/cli/src/test/test-running-unbuilt-cli.sh b/packages/playground/cli/src/test/test-running-unbuilt-cli.sh index bc13af4d5b..0ee5ec5628 100755 --- a/packages/playground/cli/src/test/test-running-unbuilt-cli.sh +++ b/packages/playground/cli/src/test/test-running-unbuilt-cli.sh @@ -14,7 +14,8 @@ function test_playground_cli() { # Run Playground CLI with a timeout. echo "Running Playground CLI with Nx target: $TARGET $@" - timeout -s TERM 30s npx nx "$TARGET" playground-cli server --php=8.3 $@ 2>&1 > playground-cli-test-output & + echo '{"version":2,"siteOptions":{"blogname":"My WordPress Website"}}' > ./blueprint.json + timeout -s TERM 30s npx nx "$TARGET" playground-cli server --blueprint=./blueprint.json --php=8.3 $@ 2>&1 > playground-cli-test-output & PID=$! CLI_STARTUP_STRING='WordPress is running on http://127.0.0.1:9400' @@ -53,6 +54,7 @@ function test_playground_cli_multi_worker() { # TODO: Also test with asyncify once we multiple workers there. test_playground_cli unbuilt-jspi \ + --truncate-new-site-directory \ --mountBeforeInstall="$MULTIWORKER_WP_PATH:/wordpress" \ --experimentalMultiWorker } diff --git a/packages/playground/cli/src/test/v2.spec.ts b/packages/playground/cli/src/test/v2.spec.ts new file mode 100644 index 0000000000..33b5699196 --- /dev/null +++ b/packages/playground/cli/src/test/v2.spec.ts @@ -0,0 +1,47 @@ +import { loadNodeRuntime } from '@php-wasm/node'; +import { type PHPRequestHandler } from '@php-wasm/universal'; +import { bootRequestHandler } from '@wp-playground/wordpress'; +import { rootCertificates } from 'node:tls'; +import { runBlueprintV2 } from '../v2'; +import { RecommendedPHPVersion } from '@wp-playground/common'; + +describe('V2 runner', () => { + let handler: PHPRequestHandler; + + beforeEach(async () => { + handler = await bootRequestHandler({ + createPhpRuntime: async () => + await loadNodeRuntime(RecommendedPHPVersion, { + emscriptenOptions: { + ENV: { + DOCROOT: '/wordpress', + }, + }, + }), + sapiName: 'cli', + siteUrl: 'http://playground-domain/', + phpIniEntries: { + 'openssl.cafile': '/internal/shared/ca-bundle.crt', + }, + createFiles: { + '/internal/shared/ca-bundle.crt': rootCertificates.join('\n'), + }, + }); + }); + + it('should put WordPress in the document root', async () => { + const instance = await handler.processManager.acquirePHPInstance(); + const result = await runBlueprintV2({ + php: instance.php as any, + blueprint: '{"version":2}', + cliArgs: [ + '--site-url=http://playground-domain/', + '--db-engine=sqlite', + ], + }); + await result.finished; + expect(await result.exitCode).toBe(0); + const instance2 = await handler.processManager.acquirePHPInstance(); + expect(instance2.php.listFiles('/wordpress')).toContain('wp-content'); + }, 60000); +}); diff --git a/packages/playground/cli/src/testcode.php b/packages/playground/cli/src/testcode.php new file mode 100644 index 0000000000..485f4a34de --- /dev/null +++ b/packages/playground/cli/src/testcode.php @@ -0,0 +1,736 @@ +zip = new ZipDecoder2( $byte_reader ); + $this->byte_reader = $byte_reader; + } + + public function ls( $parent = '/' ) { + $this->load_central_directory(); + $descendants = $this->central_directory; + + // Only keep the descendants of the given parent. + $parent = trim( $parent, '/' ); + $prefix = $parent ? $parent . '/' : ''; + if ( strlen( $prefix ) > 1 ) { + $filtered_descendants = array(); + foreach ( $descendants as $entry ) { + $path = $entry->path; + if ( strpos( $path, $prefix ) !== 0 ) { + continue; + } + $filtered_descendants[] = $entry; + } + $descendants = $filtered_descendants; + } + + // Only keep the direct children of the parent. + $children = array(); + foreach ( $descendants as $entry ) { + $suffix = rtrim( substr( $entry->path, strlen( $prefix ) ), '/' ); + if ( strpos( $suffix, '/' ) !== false ) { + continue; + } + // No need to include the directory itself. + if ( strlen( $suffix ) === 0 ) { + continue; + } + $children[] = $suffix; + } + + return $children; + } + + public function is_dir( $path ) { + if ( '/' === $path ) { + return true; + } + $this->load_central_directory(); + $path = trim( $path, '/' ) . '/'; + + return isset( $this->central_directory[ $path ] ) && $this->central_directory[ $path ]->is_directory(); + } + + public function is_file( $path ) { + $this->load_central_directory(); + $path = trim( $path, '/' ); + + return isset( $this->central_directory[ $path ] ) && ! $this->central_directory[ $path ]->is_directory(); + } + + public function exists( $path ) { + return $this->is_file( $path ) || $this->is_dir( $path ); + } + + public function open_read_stream( $path ): ByteReadStream { + if ( $this->last_file_stream !== null && ! $this->last_file_stream->reached_end_of_data() ) { + throw new FilesystemException( + 'ZipFilesystem cannot open a read stream while another read stream is open' + ); + } + $this->load_central_directory(); + $path = trim( $path, '/' ); + if ( ! isset( $this->central_directory[ $path ] ) ) { + throw new FilesystemException( + sprintf( 'File %s not found', $path ) + ); + } + if ( $this->central_directory[ $path ]->is_directory() ) { + throw new FilesystemException( + sprintf( 'Path %s is not a file', $path ) + ); + } + $this->zip->seek_to_record( $this->central_directory[ $path ]->firstByteAt ); + if ( ! $this->zip->next_object() ) { + throw new FilesystemException( + sprintf( 'Failed to open file %s', $path ) + ); + } + $this->last_file_stream = $this->zip->get_object()->body_reader; + + return $this->last_file_stream; + } + + private function load_central_directory() { + if ( null !== $this->central_directory ) { + return true; + } + + if ( $this->central_directory_size() >= self::MAX_CENTRAL_DIRECTORY_SIZE ) { + throw new FilesystemException( + sprintf( 'Central directory size %d exceeds the maximum allowed size of %d', $this->central_directory_size(), + self::MAX_CENTRAL_DIRECTORY_SIZE ) + ); + } + + // Read the central directory into memory. + $this->seek_to_central_directory_index(); + + $central_directory = array(); + while ( $this->zip->next_object() ) { + $object = $this->zip->get_object(); + if ( ! ( $object instanceof CentralDirectoryEntry ) ) { + continue; + } + $central_directory[ $object->path ] = $object; + } + + // Transform the central directory into a tree structure with + // directories and files. + foreach ( $central_directory as $entry ) { + /** + * Directory are sometimes indicated by a path + * ending with a right trailing slash. Let's remove it + * to avoid an empty entry at the end of $path_segments. + */ + $path_segments = explode( '/', $entry->path ); + + for ( $i = 0; $i < count( $path_segments ) - 1; $i ++ ) { + $path_so_far = implode( '/', array_slice( $path_segments, 0, $i + 1 ) ) . '/'; + $this->central_directory[ $path_so_far ] = new CentralDirectoryEntry( + array( + 'path' => $path_so_far, + ) + ); + } + /** + * Only create a file entry if it's not a directory. + */ + if ( substr_compare( $entry->path, '/', - strlen( '/' ) ) !== 0 ) { + $this->central_directory[ $entry->path ] = $entry; + } + } + + return true; + } + + private function central_directory_size() { + $this->collect_central_directory_end_header(); + + return $this->central_directory_end_header->centralDirectorySize; + } + + private function seek_to_central_directory_index() { + $this->collect_central_directory_end_header(); + + return $this->zip->seek_to_record( $this->central_directory_end_header->centralDirectoryOffset ); + } + + private function collect_central_directory_end_header() { + if ( null !== $this->central_directory_end_header ) { + return; + } + + $length = $this->byte_reader->length(); + $this->zip->seek_to_record( $length - 22 ); + if ( ! $this->zip->next_object() ) { + throw new FilesystemException( + 'Failed to read the end central directory index at the end of the ZIP file' + ); + } + if ( ! ( $this->zip->get_object() instanceof EndCentralDirectoryEntry ) ) { + throw new FilesystemException( + sprintf( 'Expected end central directory index at the end of the ZIP file but found %s', + get_class( $this->zip->get_object() ) ) + ); + } + + $this->central_directory_end_header = $this->zip->get_object(); + } + + public function get_meta(): array { + return []; + } + } + class ZipDecoder2 { + + const COMPRESSION_DEFLATE = 8; + const COMPRESSION_NONE = 0; + + const STATE_SCAN = 'scan'; + const STATE_FILE_ENTRY = 'file-entry'; + const STATE_CENTRAL_DIRECTORY_ENTRY_READING = 'central-directory-entry-reading'; + const STATE_END_CENTRAL_DIRECTORY_ENTRY_READING = 'end-central-directory-entry-reading'; + const STATE_OBJECT_READY = 'object-ready'; + const STATE_COMPLETE = 'complete'; + + private $state = self::STATE_SCAN; + private $object = null; + private $byte_reader; + + public function __construct( ByteReadStream $byte_reader ) { + $this->byte_reader = $byte_reader; + } + + public function reached_end_of_data(): bool { + return self::STATE_COMPLETE === $this->state; + } + + public $iterations = 0; + public function next_object(): bool { + // If we're calling next_object() when an object is ready, + // it means we want to scan for the next object. Let's clear + // the state and start scanning again. + if ( $this->state === self::STATE_OBJECT_READY ) { + $this->after_record(); + } + + while ( true ) { + // if(++$this->iterations > 15) { + // var_dump("hai here"); + // die(); + // } + var_dump([ + 'state' => $this->state, + ]); + switch ( $this->state ) { + case self::STATE_SCAN: + try { + var_dump("pulling 4 bytes"); + $this->byte_reader->pull( 4, ByteReadStream::PULL_EXACTLY ); + var_dump("pulled 4 bytes"); + } catch ( NotEnoughDataException $e ) { + $this->state = self::STATE_COMPLETE; + + return false; + } + $signature = $this->byte_reader->consume( 4 ); + // verbose_log([ + // 'signature' => $signature, + // ]); + $signature = unpack( 'V', $signature )[1]; + switch ( $signature ) { + case FileEntry::SIGNATURE: + $this->state = self::STATE_FILE_ENTRY; + break; + case CentralDirectoryEntry::SIGNATURE: + $this->state = self::STATE_CENTRAL_DIRECTORY_ENTRY_READING; + break; + case EndCentralDirectoryEntry::SIGNATURE: + $this->state = self::STATE_END_CENTRAL_DIRECTORY_ENTRY_READING; + break; + default: + throw new ByteStreamException( + sprintf( 'Invalid ZIP object signature %d', $signature ) + ); + } + break; + + case self::STATE_FILE_ENTRY: + $this->read_file_entry(); + break; + + case self::STATE_CENTRAL_DIRECTORY_ENTRY_READING: + $this->read_central_directory_entry(); + break; + + case self::STATE_END_CENTRAL_DIRECTORY_ENTRY_READING: + $this->read_end_central_directory_entry(); + break; + + case self::STATE_OBJECT_READY: + return true; + + default: + return false; + } + } + } + + public function get_object() { + return $this->object; + } + + public function seek_to_record( $record_offset ) { + $this->after_record(); + $this->byte_reader->seek( $record_offset ); + } + + private function read_file_entry() { + $this->byte_reader->pull( FileEntry::HEADER_SIZE, ByteReadStream::PULL_EXACTLY ); + $data = $this->byte_reader->consume( FileEntry::HEADER_SIZE ); + $header_fields = unpack( + 'vversion/vgeneralPurpose/vcompressionMethod/vlastModifiedTime/vlastModifiedDate/Vcrc/VcompressedSize/VuncompressedSize/vpathLength/vextraLength', + $data + ); + $this->object = new FileEntry( $header_fields ); + + $this->byte_reader->pull( $this->object->pathLength, ByteReadStream::PULL_EXACTLY ); + $path = $this->byte_reader->consume( $this->object->pathLength ); + $this->object->path = self::sanitize_path( $path ); + + $this->byte_reader->pull( $this->object->extraLength, ByteReadStream::PULL_EXACTLY ); + $extra = $this->byte_reader->consume( $this->object->extraLength ); + $this->object->extra = $extra; + + $limit_reader = new LimitedByteReadStream( + $this->byte_reader, + $this->object->compressedSize + ); + + $is_compressed = $this->object->compressionMethod === self::COMPRESSION_DEFLATE; + if ( $is_compressed ) { + $this->object->body_reader = new InflateReadStream( $limit_reader, ZLIB_ENCODING_RAW ); + } else { + $this->object->body_reader = $limit_reader; + } + $this->state = self::STATE_OBJECT_READY; + } + + private function read_central_directory_entry() { + $this->byte_reader->pull( CentralDirectoryEntry::HEADER_SIZE, ByteReadStream::PULL_EXACTLY ); + $data = $this->byte_reader->consume( CentralDirectoryEntry::HEADER_SIZE ); + $header_fields = unpack( + 'vversionCreated/vversionNeeded/vgeneralPurpose/vcompressionMethod/vlastModifiedTime/vlastModifiedDate/Vcrc/VcompressedSize/VuncompressedSize/vpathLength/vextraLength/vfileCommentLength/vdiskNumber/vinternalAttributes/VexternalAttributes/VfirstByteAt', + $data + ); + $this->object = new CentralDirectoryEntry( $header_fields ); + + $this->byte_reader->pull( $this->object->pathLength, ByteReadStream::PULL_EXACTLY ); + $path_bytes = $this->byte_reader->consume( $this->object->pathLength ); + $this->object->path = self::sanitize_path( $path_bytes ); + + $this->byte_reader->pull( $this->object->extraLength, ByteReadStream::PULL_EXACTLY ); + $extra_bytes = $this->byte_reader->consume( $this->object->extraLength ); + $this->object->extra = $extra_bytes; + + $this->byte_reader->pull( $this->object->fileCommentLength, ByteReadStream::PULL_EXACTLY ); + $file_comment_bytes = $this->byte_reader->consume( $this->object->fileCommentLength ); + $this->object->fileComment = $file_comment_bytes; + $this->state = self::STATE_OBJECT_READY; + } + + private function read_end_central_directory_entry() { + $this->byte_reader->pull( EndCentralDirectoryEntry::HEADER_SIZE, ByteReadStream::PULL_EXACTLY ); + $data = $this->byte_reader->consume( EndCentralDirectoryEntry::HEADER_SIZE ); + $header_fields = unpack( + 'vdiskNumber/vcentralDirectoryStartDisk/vnumberCentralDirectoryRecordsOnThisDisk/vnumberCentralDirectoryRecords/VcentralDirectorySize/VcentralDirectoryOffset/vcommentLength', + $data + ); + $this->object = new EndCentralDirectoryEntry( + $header_fields + ); + + $this->byte_reader->pull( $this->object->commentLength, ByteReadStream::PULL_EXACTLY ); + $comment_bytes = $this->byte_reader->consume( $this->object->commentLength ); + $this->object->comment = $comment_bytes; + $this->state = self::STATE_OBJECT_READY; + } + + private function after_record() { + if ( $this->object instanceof FileEntry ) { + // Skip past the file bytes + $this->object->body_reader->consume_all(); + $this->object->body_reader->close_reading(); + } + $this->state = self::STATE_SCAN; + $this->object = null; + } + + /** + * Normalizes the parsed path to prevent directory traversal, + * a.k.a zip slip attacks. + * + * In ZIP, paths are arbitrary byte sequences. Nothing prevents + * a ZIP file from containing a path such as /etc/passwd or + * ../../../../etc/passwd. + * + * This function normalizes paths found in the ZIP file. + * + * @TODO: Scrutinize the implementation of this function. Consider + * unicode characters in the path, including ones that are + * just embelishments of the following character. Consider + * the impact of **all** seemingly "invalid" byte sequences, + * e.g. spaces, ASCII control characters, etc. What will the + * OS do when it receives a path containing .{null byte}./etc/passwd? + */ + public static function sanitize_path( $path ) { + // Replace multiple slashes with a single slash. + $path = preg_replace( '#/+#', '/', $path ); + // Remove all the leading ../ segments. + $path = preg_replace( '#^(\.\./)+#', '', $path ); + // Remove all the /./ and /../ segments. + $path = preg_replace( '#/\.\.?/#', '/', $path ); + + return $path; + } + } + + class SeekableRequestReadStream2 implements WordPress\ByteStream\ReadStream\ByteReadStream { + + public $remote; // RequestReadStream + private $cache; // FileReadWriteStream + private $temp; + private $length_resolved = false; + + public function __construct( $request, array $options = array() ) { + verbose_log("SeekableRequestReadStream::__construct() - START"); + if ( is_string( $request ) ) { + verbose_log("SeekableRequestReadStream::__construct() - Converting string request to Request object"); + $request = new Request( $request ); + } + verbose_log("SeekableRequestReadStream::__construct() - Creating RequestReadStream"); + $this->remote = new RequestReadStream( $request, $options ); + verbose_log("SeekableRequestReadStream::__construct() - RequestReadStream created"); + + $this->temp = $options['cache_path'] ?? tempnam( sys_get_temp_dir(), 'wp_http_cache_' ); + verbose_log("SeekableRequestReadStream::__construct() - Temp file: " . $this->temp); + + verbose_log("SeekableRequestReadStream::__construct() - Creating FileReadWriteStream"); + $this->cache = FileReadWriteStream::from_path( $this->temp, true ); + verbose_log("SeekableRequestReadStream::__construct() - END"); + } + + private function pipe_until( int $offset ): void { + verbose_log("SeekableRequestReadStream::pipe_until() - START - target offset: $offset"); + $iteration = 0; + while ( $this->cache->length() === null || $this->cache->length() < $offset ) { + $iteration++; + $cache_length = $this->cache->length(); + verbose_log("SeekableRequestReadStream::pipe_until() - iteration $iteration - cache length: " . ($cache_length ?? 'null') . ", target: $offset"); + + verbose_log("SeekableRequestReadStream::pipe_until() - calling remote->pull()"); + $pulled = $this->remote->pull( BaseByteReadStream::CHUNK_SIZE ); + verbose_log("SeekableRequestReadStream::pipe_until() - remote->pull() returned: $pulled bytes"); + + if ( 0 === $pulled ) { + verbose_log("SeekableRequestReadStream::pipe_until() - No more data pulled, breaking"); + break; + } + + verbose_log("SeekableRequestReadStream::pipe_until() - calling remote->consume()"); + $data = $this->remote->consume( $pulled ); + verbose_log("SeekableRequestReadStream::pipe_until() - remote->consume() returned " . strlen($data) . " bytes"); + + verbose_log("SeekableRequestReadStream::pipe_until() - calling cache->append_bytes()"); + $this->cache->append_bytes( $data ); + verbose_log("SeekableRequestReadStream::pipe_until() - cache->append_bytes() completed"); + + // Safety check to prevent infinite loops + if ($iteration > 10000) { + verbose_log("SeekableRequestReadStream::pipe_until() - WARNING: Too many iterations ($iteration), breaking to prevent infinite loop"); + break; + } + } + verbose_log("SeekableRequestReadStream::pipe_until() - END - final cache length: " . ($this->cache->length() ?? 'null')); + } + + public function length(): ?int { + verbose_log("SeekableRequestReadStream::length() - START"); + if ( ! $this->length_resolved && null === $this->remote->length() ) { + verbose_log("SeekableRequestReadStream::length() - Length not resolved and remote length is null"); + /** + * Wait for the remote headers before returning the length. + * + * This is an inconsistency between RequestReadStream::length(): + * + * * RequestReadStream returns null until the remote headers are known. + * * SeekableRequestReadStream proactively waits for the remote headers. + * + * That's because: + * + * * RequestReadStream class is a lower-level utility where we simply + * expose what's available at the moment. The developer is responsible + * for awaiting the response headers. + * * SeekableRequestReadStream is a higher-level tool meant for usage + * when knowing the length is vital, e.g. reading from a remote ZIP file. + */ + verbose_log("SeekableRequestReadStream::length() - calling remote->await_response()"); + $this->remote->await_response(); + verbose_log("SeekableRequestReadStream::length() - remote->await_response() completed"); + + if ( null === $this->remote->length() ) { + verbose_log("SeekableRequestReadStream::length() - Remote length still null, consuming entire stream"); + // The server did not send the Content-Length header. + // We need to consume the entire stream to infer the length. + $position = $this->tell(); + verbose_log("SeekableRequestReadStream::length() - Current position: $position"); + + verbose_log("SeekableRequestReadStream::length() - calling consume_all()"); + $this->consume_all(); + verbose_log("SeekableRequestReadStream::length() - consume_all() completed"); + + verbose_log("SeekableRequestReadStream::length() - seeking back to position: $position"); + $this->seek( $position ); + verbose_log("SeekableRequestReadStream::length() - seek completed"); + } + $this->length_resolved = true; + verbose_log("SeekableRequestReadStream::length() - Length resolved"); + } + + $result = $this->remote->length(); + verbose_log("SeekableRequestReadStream::length() - END - returning: " . ($result ?? 'null')); + return $result; + } + + public function tell(): int { + verbose_log("SeekableRequestReadStream::tell() - START"); + $result = $this->cache->tell(); + verbose_log("SeekableRequestReadStream::tell() - END - returning: $result"); + return $result; + } + + public function seek( int $offset ) { + verbose_log("SeekableRequestReadStream::seek() - START - offset: $offset"); + verbose_log("SeekableRequestReadStream::seek() - calling pipe_until()"); + $this->pipe_until( $offset ); + verbose_log("SeekableRequestReadStream::seek() - pipe_until() completed"); + + var_dump("SeekableRequestReadStream::seek() - calling cache->seek()"); + var_dump($this->cache->tell()); + $this->cache->seek( $offset ); + verbose_log("SeekableRequestReadStream::seek() - END"); + } + + public function reached_end_of_data(): bool { + verbose_log("SeekableRequestReadStream::reached_end_of_data() - START"); + $remote_end = $this->remote->reached_end_of_data(); + $cache_end = $this->cache->reached_end_of_data(); + $result = $remote_end && $cache_end; + verbose_log("SeekableRequestReadStream::reached_end_of_data() - remote_end: " . ($remote_end ? 'true' : 'false') . ", cache_end: " . ($cache_end ? 'true' : 'false') . ", result: " . ($result ? 'true' : 'false')); + return $result; + } + + public function pull( $n, $mode = self::PULL_NO_MORE_THAN ): int { + verbose_log("SeekableRequestReadStream::pull() - START - n: $n, mode: $mode"); + $current_pos = $this->tell(); + verbose_log("SeekableRequestReadStream::pull() - current position: $current_pos"); + + verbose_log("SeekableRequestReadStream::pull() - calling pipe_until()"); + $this->pipe_until( $current_pos + $n ); + verbose_log("SeekableRequestReadStream::pull() - pipe_until() completed"); + + verbose_log("SeekableRequestReadStream::pull() - calling cache->pull()"); + $result = $this->cache->pull( $n, $mode ); + verbose_log("SeekableRequestReadStream::pull() - END - returning: $result"); + return $result; + } + + public function peek( $n ): string { + verbose_log("SeekableRequestReadStream::peek() - START - n: $n"); + $current_pos = $this->tell(); + verbose_log("SeekableRequestReadStream::peek() - current position: $current_pos"); + + verbose_log("SeekableRequestReadStream::peek() - calling pipe_until()"); + $this->pipe_until( $current_pos + $n ); + verbose_log("SeekableRequestReadStream::peek() - pipe_until() completed"); + + verbose_log("SeekableRequestReadStream::peek() - calling cache->peek()"); + $result = $this->cache->peek( $n ); + verbose_log("SeekableRequestReadStream::peek() - END - returning " . strlen($result) . " bytes"); + return $result; + } + + public function consume( $n ): string { + verbose_log("SeekableRequestReadStream::consume() - START - n: $n"); + verbose_log("SeekableRequestReadStream::consume() - calling cache->consume()"); + $result = $this->cache->consume( $n ); + verbose_log("SeekableRequestReadStream::consume() - END - returning " . strlen($result) . " bytes"); + return $result; + } + + public function consume_all(): string { + global $did_download_wordpress_zip; + verbose_log("SeekableRequestReadStream::consume_all() - START"); + $iteration = 0; + while ( ! $this->remote->reached_end_of_data() ) { + $iteration++; + verbose_log("SeekableRequestReadStream::consume_all() - iteration $iteration"); + + verbose_log("SeekableRequestReadStream::consume_all() - calling remote->pull()"); + $pulled = $this->remote->pull( BaseByteReadStream::CHUNK_SIZE ); + verbose_log("SeekableRequestReadStream::consume_all() - remote->pull() returned: $pulled bytes"); + + if ( 0 === $pulled ) { + verbose_log("SeekableRequestReadStream::consume_all() - No more data pulled, breaking"); + break; + } + + verbose_log("SeekableRequestReadStream::consume_all() - calling remote->consume()"); + $data = $this->remote->consume( $pulled ); + verbose_log("SeekableRequestReadStream::consume_all() - remote->consume() returned " . strlen($data) . " bytes"); + + verbose_log("SeekableRequestReadStream::consume_all() - calling cache->append_bytes()"); + $this->cache->append_bytes( $data ); + verbose_log("SeekableRequestReadStream::consume_all() - cache->append_bytes() completed"); + + // Safety check to prevent infinite loops + if ($iteration > 10000) { + var_dump("SeekableRequestReadStream::consume_all() - WARNING: Too many iterations ($iteration), breaking to prevent infinite loop"); + break; + } + } + verbose_log("SeekableRequestReadStream::consume_all() - calling cache->close_writing()"); + $this->cache->close_writing(); + verbose_log("SeekableRequestReadStream::consume_all() - cache->close_writing() completed"); + + verbose_log("SeekableRequestReadStream::consume_all() - calling cache->consume_all()"); + $result = $this->cache->consume_all(); + $did_download_wordpress_zip = true; + verbose_log("SeekableRequestReadStream::consume_all() - END - returning " . strlen($result) . " bytes"); + return $result; + } + + public function await_response() { + verbose_log("SeekableRequestReadStream::await_response() - START"); + verbose_log("SeekableRequestReadStream::await_response() - calling remote->await_response()"); + $result = $this->remote->await_response(); + verbose_log("SeekableRequestReadStream::await_response() - END"); + return $result; + } + + public function close_reading(): void { + verbose_log("SeekableRequestReadStream::close_reading() - START"); + verbose_log("SeekableRequestReadStream::close_reading() - calling remote->close_reading()"); + $this->remote->close_reading(); + verbose_log("SeekableRequestReadStream::close_reading() - remote->close_reading() completed"); + + verbose_log("SeekableRequestReadStream::close_reading() - calling cache->close_reading()"); + $this->cache->close_reading(); + verbose_log("SeekableRequestReadStream::close_reading() - cache->close_reading() completed"); + + verbose_log("SeekableRequestReadStream::close_reading() - unlinking temp file: " . $this->temp); + @unlink( $this->temp ); + } + } + + $client = new WordPress\HttpClient\Client([ + // sockets transport is somehow faster than curl in Playground. Maybe + // it uses a larger chunk size? + // 'transport' => 'curl', + ]); + + $stream = new SeekableRequestReadStream2( + new WordPress\HttpClient\Request('https://wordpress.org/latest.zip'), + [ + 'client' => $client, + ] + ); + $fs = ZipFilesystem::create($stream); + var_dump($fs->ls()); + + // $decoder = new ZipDecoder2($stream); + // var_dump($decoder->next_object()); + // var_dump($decoder->next_object()); + // var_dump($decoder->next_object()); + // var_dump('done'); + // die(); + $stream = new WordPress\HttpClient\ByteStream\SeekableRequestReadStream( + new WordPress\HttpClient\Request('https://downloads.wordpress.org/plugin/simple-local-avatars.latest-stable.zip'), + [ + 'client' => $client, + ] + ); + $fs = WordPress\Zip\ZipFilesystem::create($stream); + verbose_log($fs->ls()); + die(); + return $client; + +} +playground_add_filter('blueprint.http_client', 'playground_http_client_factory'); \ No newline at end of file diff --git a/packages/playground/cli/src/v2.ts b/packages/playground/cli/src/v2.ts new file mode 100644 index 0000000000..a7eb88d23f --- /dev/null +++ b/packages/playground/cli/src/v2.ts @@ -0,0 +1,300 @@ +import { logger } from '@php-wasm/logger'; +import { + type StreamedPHPResponse, + type UniversalPHP, +} from '@php-wasm/universal'; +import { phpVar } from '@php-wasm/util'; +import type { BlueprintDeclaration } from '@wp-playground/blueprints'; + +export type PHPExceptionDetails = { + exception: string; + message: string; + file: string; + line: number; + trace: string; +}; + +export type BlueprintMessage = + | { type: 'blueprint.target_resolved' } + | { type: 'blueprint.progress'; progress: number; caption: string } + | { + type: 'blueprint.error'; + message: string; + details?: PHPExceptionDetails; + } + | { type: 'blueprint.completion'; message: string }; + +interface RunV2Options { + php: UniversalPHP; + cliArgs?: string[]; + blueprint: BlueprintV2Declaration | ParsedBlueprintV2Declaration; + blueprintOverrides?: { + wordpressVersion?: string; + additionalSteps?: any[]; + }; + onMessage?: (message: BlueprintMessage) => void | Promise; +} + +export type BlueprintV2Declaration = string | BlueprintDeclaration | undefined; +export type ParsedBlueprintV2Declaration = + | { type: 'inline-file'; contents: string } + | { type: 'file-reference'; reference: string }; + +export function parseBlueprintDeclaration( + source: BlueprintV2Declaration | ParsedBlueprintV2Declaration +): ParsedBlueprintV2Declaration { + if ( + typeof source === 'object' && + 'type' in source && + ['inline-file', 'file-reference'].includes(source.type) + ) { + return source; + } + if (!source) { + return { + type: 'inline-file', + contents: '{}', + }; + } + if (typeof source !== 'string') { + // If source is an object, assume it's a Blueprint declaration object and + // convert it to a JSON string. + return { + type: 'inline-file', + contents: JSON.stringify(source), + }; + } + try { + // If source is valid JSON, return it as is. + JSON.parse(source); + return { + type: 'inline-file', + contents: source, + }; + } catch { + return { + type: 'file-reference', + reference: source, + }; + } +} + +export async function getV2Runner(): Promise { + let data = null; + + /** + * Avoid a static dependency for now. + * + * Playground.wordpress.net does not need to know about the new runner yet, and + * a static import would force it to download the v2 runner even when it's not needed. + * This breaks the offline mode as the static assets list is not yet updated to accommodate + * for the new .phar file. + */ + // @ts-ignore + const v2_runner_url = (await import('../public/blueprints.phar?url')) + .default; + + /** + * Only load the v2 runner via node:fs when running in Node.js. + */ + if (typeof process !== 'undefined' && process.versions?.node) { + let path = v2_runner_url; + if (path.startsWith('/@fs/')) { + path = path.slice('/@fs'.length); + } + if (path.startsWith('file://')) { + path = path.slice('file://'.length); + } + + const { readFile } = await import('node:fs/promises'); + data = await readFile(path); + } else { + const response = await fetch(v2_runner_url); + data = await response.blob(); + } + return new File([data], `blueprints.phar`, { + type: 'application/zip', + }); +} + +export async function runBlueprintV2( + options: RunV2Options +): Promise { + const cliArgs = options.cliArgs || []; + for (const arg of cliArgs) { + if (arg.startsWith('--site-path=')) { + throw new Error( + 'The --site-path CLI argument must not be provided. In Playground, it is always set to /wordpress.' + ); + } + } + cliArgs.push('--site-path=/wordpress'); + + /** + * Divergence from blueprints.phar – the default database engine is + * SQLite. Why? Because in Playground we'll use SQLite far more often than + * MySQL. + */ + const dbEngine = cliArgs.find((arg) => arg.startsWith('--db-engine=')); + if (!dbEngine) { + cliArgs.push('--db-engine=sqlite'); + } + + const php = options.php; + const onMessage = options?.onMessage || (() => {}); + + const file = await getV2Runner(); + php.writeFile( + '/tmp/blueprints.phar', + new Uint8Array(await file.arrayBuffer()) + ); + + const parsedBlueprintDeclaration = parseBlueprintDeclaration( + options.blueprint + ); + let blueprintReference = ''; + switch (parsedBlueprintDeclaration.type) { + case 'inline-file': + php.writeFile( + '/tmp/blueprint.json', + parsedBlueprintDeclaration.contents + ); + blueprintReference = '/tmp/blueprint.json'; + break; + case 'file-reference': + blueprintReference = parsedBlueprintDeclaration.reference; + break; + } + + const unbindMessageListener = await php.onMessage(async (message) => { + try { + const parsed = + typeof message === 'string' ? JSON.parse(message) : message; + if (!parsed) { + return; + } + + // Make sure stdout and stderr data is emited before the next message is processed. + // Otherwise a code such as `echo "Hello"; post_message_to_js(json_encode([ + // 'type' => 'blueprint.error', + // 'message' => 'Error' + // ]));` + // might emit the message before we process the stdout data. + // + // This is a workaround to ensure that the message is emitted after the stdout data is processed. + // @TODO: Remove this workaround. Find the root cause why stdout data is delayed and address it + // directly. + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (parsed.type.startsWith('blueprint.')) { + await onMessage(parsed); + } + } catch (e) { + logger.warn('Failed to parse message as JSON:', message, e); + } + }); + + /** + * Prepare hooks, filters, and run the Blueprint: + */ + await php?.writeFile( + '/tmp/run-blueprints.php', + ` 'sockets', + ]); +} +playground_add_filter('blueprint.http_client', 'playground_http_client_factory'); + +function playground_on_blueprint_target_resolved() { + post_message_to_js(json_encode([ + 'type' => 'blueprint.target_resolved', + ])); +} +playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); + +playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); +function playground_on_blueprint_resolved($blueprint) { + $additional_blueprint_steps = json_decode(${phpVar( + JSON.stringify(options.blueprintOverrides?.additionalSteps || []) + )}, true); + if(count($additional_blueprint_steps) > 0) { + $blueprint['additionalStepsAfterExecution'] = array_merge( + $blueprint['additionalStepsAfterExecution'] ?? [], + $additional_blueprint_steps + ); + } + + $wp_version_override = json_decode(${phpVar( + JSON.stringify(options.blueprintOverrides?.wordpressVersion || null) + )}, true); + if($wp_version_override) { + $blueprint['wordpressVersion'] = $wp_version_override; + } + return $blueprint; +} + +function playground_progress_reporter() { + class PlaygroundProgressReporter implements ProgressReporter { + + public function reportProgress(float $progress, string $caption): void { + $this->writeJsonMessage([ + 'type' => 'blueprint.progress', + 'progress' => round($progress, 2), + 'caption' => $caption + ]); + } + + public function reportError(string $message, ?Throwable $exception = null): void { + $errorData = [ + 'type' => 'blueprint.error', + 'message' => $message + ]; + + if ($exception) { + $errorData['details'] = [ + 'exception' => get_class($exception), + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString() + ]; + } + + $this->writeJsonMessage($errorData); + } + + public function reportCompletion(string $message): void { + $this->writeJsonMessage([ + 'type' => 'blueprint.completion', + 'message' => $message + ]); + } + + public function close(): void {} + + private function writeJsonMessage(array $data): void { + post_message_to_js(json_encode($data)); + } + } + return new PlaygroundProgressReporter(); +} +playground_add_filter('blueprint.progress_reporter', 'playground_progress_reporter'); +require( "/tmp/blueprints.phar" ); +` + ); + const streamedResponse = (await (php as any).cli([ + '/internal/shared/bin/php', + '/tmp/run-blueprints.php', + 'exec', + blueprintReference, + ...cliArgs, + ])) as StreamedPHPResponse; + + streamedResponse.finished.finally(unbindMessageListener); + + return streamedResponse; +} diff --git a/packages/playground/cli/src/worker-thread.ts b/packages/playground/cli/src/worker-thread.ts index cde5737319..6180550426 100644 --- a/packages/playground/cli/src/worker-thread.ts +++ b/packages/playground/cli/src/worker-thread.ts @@ -1,37 +1,40 @@ -import type { PHP, SupportedPHPVersion } from '@php-wasm/universal'; -import { PHPWorker, consumeAPI, exposeAPI } from '@php-wasm/universal'; +import { errorLogPath } from '@php-wasm/logger'; import type { FileLockManager } from '@php-wasm/node'; import { createNodeFsMountHandler, loadNodeRuntime } from '@php-wasm/node'; import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; -import { bootWordPress } from '@wp-playground/wordpress'; +import type { PHP, SupportedPHPVersion } from '@php-wasm/universal'; +import { + PHPExecutionFailureError, + PHPResponse, + PHPWorker, + consumeAPI, + exposeAPI, + sandboxedSpawnHandlerFactory, +} from '@php-wasm/universal'; import { sprintf } from '@php-wasm/util'; -import { parentPort } from 'worker_threads'; +import { runBlueprintV2, type BlueprintMessage } from './v2'; +import { bootRequestHandler } from '@wp-playground/wordpress'; +import { existsSync } from 'fs'; +import path from 'path'; import { rootCertificates } from 'tls'; +import { parentPort } from 'worker_threads'; +import type { Mount } from './mounts'; +import type { RunCLIArgs } from './run-cli'; -export interface Mount { - hostPath: string; - vfsPath: string; -} - -export type PrimaryWorkerBootOptions = { - wpVersion?: string; - phpVersion?: SupportedPHPVersion; - absoluteUrl: string; - mountsBeforeWpInstall: Array; - mountsAfterWpInstall: Array; - wordPressZip?: ArrayBuffer; - sqliteIntegrationPluginZip?: ArrayBuffer; - firstProcessId: number; - processIdSpaceLength: number; - dataSqlPath?: string; - followSymlinks: boolean; - trace: boolean; -}; - -function mountResources(php: PHP, mounts: Mount[]) { +async function mountResources(php: PHP, mounts: Mount[]) { for (const mount of mounts) { - php.mkdir(mount.vfsPath); - php.mount(mount.vfsPath, createNodeFsMountHandler(mount.hostPath)); + try { + php.mkdir(mount.vfsPath); + await php.mount( + mount.vfsPath, + createNodeFsMountHandler(mount.hostPath) + ); + } catch { + output.stderr( + `\x1b[31m\x1b[1mError mounting path ${mount.hostPath} at ${mount.vfsPath}\x1b[0m\n` + ); + process.exit(1); + } } } @@ -51,6 +54,70 @@ function tracePhpWasm(processId: number, format: string, ...args: any[]) { ); } +/** + * Force TTY status to preserve ANSI control codes in the output. + * + * This script is spawned as `new Worker()` and process.stdout and process.stderr are + * WritableWorkerStdio objects. By default, they strip ANSI control codes from the output + * causing every progress bar update to be printed in a new line instead of updating the + * same line. + */ +Object.defineProperty(process.stdout, 'isTTY', { value: true }); +Object.defineProperty(process.stderr, 'isTTY', { value: true }); + +/** + * Output writer that ensures that progress bars are not printed on the same line as other output. + */ +const output = { + lastWriteWasProgress: false, + progress(data: string) { + if (!process.stdout.isTTY) { + // eslint-disable-next-line no-console + console.log(data); + } else { + if (!output.lastWriteWasProgress) { + process.stdout.write('\n'); + } + process.stdout.write('\r\x1b[K' + data); + output.lastWriteWasProgress = true; + } + }, + stdout(data: string) { + if (output.lastWriteWasProgress) { + process.stdout.write('\n'); + output.lastWriteWasProgress = false; + } + process.stdout.write(data); + }, + stderr(data: string) { + if (output.lastWriteWasProgress) { + process.stdout.write('\n'); + output.lastWriteWasProgress = false; + } + process.stderr.write(data); + }, +}; + +export interface WorkerBootArgs extends RunCLIArgs { + siteUrl: string; + firstProcessId: number; + processIdSpaceLength: number; + trace: boolean; +} + +interface WorkerRunBlueprintArgs extends RunCLIArgs { + siteUrl: string; +} + +interface WorkerBootRequestHandlerOptions { + siteUrl: string; + php: SupportedPHPVersion; + allow?: string; + firstProcessId: number; + processIdSpaceLength: number; + trace: boolean; +} + export class PlaygroundCliWorker extends PHPWorker { booted = false; @@ -58,19 +125,181 @@ export class PlaygroundCliWorker extends PHPWorker { super(undefined, monitor); } - async boot({ - absoluteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - phpVersion = '8.0', - wordPressZip, - sqliteIntegrationPluginZip, + async bootAsPrimaryWorker(args: WorkerBootArgs) { + await this.bootRequestHandler(args); + + const primaryPhp = this.__internal_getPHP()!; + await mountResources(primaryPhp, args['mount-before-install'] || []); + + if (args.mode === 'mount-only') { + await mountResources(primaryPhp, args.mount || []); + return; + } + + await this.runBlueprintV2(args); + } + + async bootAsSecondaryWorker(args: WorkerBootArgs) { + await this.bootRequestHandler(args); + const primaryPhp = this.__internal_getPHP()!; + // When secondary workers are spawned, WordPress is already installed. + await mountResources(primaryPhp, args['mount-before-install'] || []); + await mountResources(primaryPhp, args.mount || []); + } + + async runBlueprintV2(args: WorkerRunBlueprintArgs) { + const requestHandler = this.__internal_getRequestHandler()!; + const { php, reap } = + await requestHandler.processManager.acquirePHPInstance({ + considerPrimary: false, + }); + + // Mount the current working directory to the PHP runtime for the purposes of + // Blueprint resolution. + const primaryPhp = this.__internal_getPHP()!; + let unmountCwd = () => {}; + if (typeof args.blueprint === 'string') { + const blueprintPath = path.resolve(process.cwd(), args.blueprint); + if (existsSync(blueprintPath)) { + primaryPhp.mkdir('/internal/shared/cwd'); + unmountCwd = await primaryPhp.mount( + '/internal/shared/cwd', + createNodeFsMountHandler(path.dirname(blueprintPath)) + ); + args.blueprint = path.join( + '/internal/shared/cwd', + path.basename(args.blueprint) + ); + } + } + + try { + const cliArgsToPass: (keyof WorkerRunBlueprintArgs)[] = [ + 'mode', + 'db-engine', + 'db-host', + 'db-user', + 'db-pass', + 'db-name', + 'db-path', + 'truncate-new-site-directory', + 'allow', + ]; + const cliArgs = cliArgsToPass + .filter((arg) => arg in args) + .map((arg) => `--${arg}=${args[arg]}`); + cliArgs.push(`--site-url=${args.siteUrl}`); + + let afterBlueprintTargetResolvedCalled = false; + + const streamedResponse = await runBlueprintV2({ + php, + blueprint: args.blueprint, + blueprintOverrides: { + additionalSteps: args['additional-blueprint-steps'], + wordpressVersion: args.wp, + }, + cliArgs, + onMessage: async (message: BlueprintMessage) => { + switch (message.type) { + case 'blueprint.target_resolved': { + if (!afterBlueprintTargetResolvedCalled) { + await mountResources( + primaryPhp, + args.mount || [] + ); + afterBlueprintTargetResolvedCalled = true; + } + break; + } + case 'blueprint.progress': { + const progressMessage = `${message.caption.trim()} – ${message.progress.toFixed( + 2 + )}%`; + output.progress(progressMessage); + break; + } + case 'blueprint.error': { + const red = '\x1b[31m'; + const bold = '\x1b[1m'; + const reset = '\x1b[0m'; + if (args.debug && message.details) { + output.stderr( + `${red}${bold}Fatal error:${reset} Uncaught ${message.details.exception}: ${message.details.message}\n` + + ` at ${message.details.file}:${message.details.line}\n` + + (message.details.trace + ? message.details.trace + '\n' + : '') + ); + } else { + output.stderr( + `${red}${bold}Error:${reset} ${message.message}\n` + ); + } + break; + } + } + }, + }); + /** + * When we're debugging, every bit of information matters – let's immediately output + * everything we get from the PHP output streams. + */ + if (args.debug) { + streamedResponse!.stdout.pipeTo( + new WritableStream({ + write(chunk) { + process.stdout.write(chunk); + }, + }) + ); + streamedResponse!.stderr.pipeTo( + new WritableStream({ + write(chunk) { + process.stderr.write(chunk); + }, + }) + ); + } + await streamedResponse!.finished; + if ((await streamedResponse!.exitCode) !== 0) { + // exitCode != 1 means the blueprint execution failed. Let's throw an error. + // and clean up. + const syncResponse = await PHPResponse.fromStreamedResponse( + streamedResponse + ); + throw new PHPExecutionFailureError( + `PHP.run() failed with exit code ${syncResponse.exitCode}.`, + syncResponse, + 'request' + ); + } + } catch (error) { + // Capture the PHP error log details to provide more context for debugging. + let phpLogs = ''; + try { + // @TODO: Don't assume errorLogPath starts with /wordpress/ + // ...or maybe we can assume that in Playground CLI? + phpLogs = php.readFileAsText(errorLogPath); + } catch { + // Ignore errors reading the PHP error log. + } + (error as any).phpLogs = phpLogs; + throw error; + } finally { + reap(); + unmountCwd(); + } + } + + async bootRequestHandler({ + siteUrl, + allow, + php, firstProcessId, processIdSpaceLength, - dataSqlPath, - followSymlinks, trace, - }: PrimaryWorkerBootOptions) { + }: WorkerBootRequestHandlerOptions) { if (this.booted) { throw new Error('Playground already booted'); } @@ -89,8 +318,8 @@ export class PlaygroundCliWorker extends PHPWorker { WP_DEBUG_DISPLAY: false, }; - const requestHandler = await bootWordPress({ - siteUrl: absoluteUrl, + const requestHandler = await bootRequestHandler({ + siteUrl, createPhpRuntime: async () => { const processId = nextProcessId; @@ -101,26 +330,18 @@ export class PlaygroundCliWorker extends PHPWorker { nextProcessId = firstProcessId; } - return await loadNodeRuntime(phpVersion, { + return await loadNodeRuntime(php, { emscriptenOptions: { fileLockManager, processId, trace: trace ? tracePhpWasm : undefined, + ENV: { + DOCROOT: '/wordpress', + }, }, - followSymlinks, + followSymlinks: allow?.includes('follow-symlinks'), }); }, - wordPressZip: - wordPressZip !== undefined - ? new File([wordPressZip], 'wordpress.zip') - : undefined, - sqliteIntegrationPluginZip: - sqliteIntegrationPluginZip !== undefined - ? new File( - [sqliteIntegrationPluginZip], - 'sqlite-integration-plugin.zip' - ) - : undefined, sapiName: 'cli', createFiles: { '/internal/shared/ca-bundle.crt': @@ -129,24 +350,15 @@ export class PlaygroundCliWorker extends PHPWorker { constants, phpIniEntries: { 'openssl.cafile': '/internal/shared/ca-bundle.crt', - allow_url_fopen: '1', - disable_functions: '', - }, - hooks: { - async beforeWordPressFiles(php) { - mountResources(php, mountsBeforeWpInstall); - }, }, cookieStore: false, - dataSqlPath, + spawnHandler: sandboxedSpawnHandlerFactory, }); this.__internal_setRequestHandler(requestHandler); const primaryPhp = await requestHandler.getPrimaryPhp(); await this.setPrimaryPHP(primaryPhp); - mountResources(primaryPhp, mountsAfterWpInstall); - setApiReady(); } catch (e) { setAPIError(e as Error); diff --git a/packages/playground/cli/vite.config.ts b/packages/playground/cli/vite.config.ts index 77fa84d645..6fa2ed0667 100644 --- a/packages/playground/cli/vite.config.ts +++ b/packages/playground/cli/vite.config.ts @@ -1,11 +1,44 @@ /// import { join } from 'path'; -import { defineConfig } from 'vite'; +import { type PluginOption, defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { getExternalModules } from '../../vite-extensions/vite-external-modules'; + +/** + * @TODO: Consider rsbuild for this: + * import { defineConfig } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; + +export default defineConfig({ + plugins: [pluginReact()], + source: { + assetsInclude: /\.dat$/, + }, + output: { + dataUriLimit: 0, + chunkFormat: "commonjs", + target, + }, + module: { + rules: [ + { + test: /\.dat/, + use: [ + { + loader: "url-loader", + }, + ], + type: "asset/inline", + }, + ], + }, +}); + */ const plugins = [ dts({ entryRoot: 'src', @@ -16,6 +49,56 @@ const plugins = [ viteTsConfigPaths({ root: '../../../', }), + /** + * In library mode, Vite bundles all `?url` imports as JS modules with a single, + * base64 export. blueprints.phar is too large for that. We need to preserve it + * as an actual file. + * + * ... more comment tbd ... + * + * @see https://github.com/vitejs/vite/issues/3295 + */ + { + name: 'build-phars-as-URL-modules-not-data-imports', + + transform(code, id) { + if (id?.includes('.phar')) { + // @TODO don't hardcode it + // @TODO use URL on the web and path on Node.js + return { + code: ` + import { fileURLToPath } from 'url'; + import { dirname, join } from 'path'; + + let pharPath; + if (typeof __dirname !== 'undefined') { + // CommonJS + pharPath = join(__dirname, "./blueprints.phar"); + } else { + // ESM + pharPath = join(import.meta.dirname, "./blueprints.phar"); + } + + export default pharPath; + `, + map: null, + }; + } + }, + }, +] as PluginOption[]; + +const external = [ + ...getExternalModules(), + '@php-wasm/node', + '@php-wasm/web', + '@php-wasm/universal', + '@php-wasm/logger', + '@php-wasm/progress', + '@php-wasm/util', + '@wp-playground/wordpress', + '@wp-playground/common', + '@wp-playground/blueprints', ]; export default defineConfig({ @@ -29,17 +112,7 @@ export default defineConfig({ format: 'es', plugins: () => plugins, rollupOptions: { - external: [ - '@php-wasm/universal', - '@php-wasm/node', - '@php-wasm/progress', - '@wp-playground/common', - '@wp-playground/wordpress', - '@php-wasm/logger', - 'net', - 'tls', - 'worker_threads', - ], + external, output: { entryFileNames: (/* chunkInfo: any */) => { return '[name]-[hash].js'; @@ -51,38 +124,12 @@ export default defineConfig({ // Configuration for building your library. // See: https://vitejs.dev/guide/build.html#library-mode build: { + assetsDir: '', assetsInlineLimit: 0, target: 'es2020', sourcemap: true, rollupOptions: { - external: [ - '@php-wasm/node', - '@php-wasm/universal', - '@php-wasm/logger', - '@php-wasm/progress', - '@php-wasm/util', - '@wp-playground/wordpress', - '@wp-playground/common', - '@wp-playground/blueprints', - 'yargs', - 'express', - 'crypto', - 'os', - 'net', - 'fs', - 'fs-extra', - 'path', - 'child_process', - 'http', - 'path', - 'tls', - 'util', - 'dns', - 'ws', - 'readline', - 'worker_threads', - 'url', - ], + external, }, lib: { entry: { diff --git a/packages/playground/common/src/index.ts b/packages/playground/common/src/index.ts index 48a051181a..3c6f5440f8 100644 --- a/packages/playground/common/src/index.ts +++ b/packages/playground/common/src/index.ts @@ -72,12 +72,12 @@ export const unzipFile = async ( await php.unlink(tmpPath); } }; - export const zipDirectory = async ( php: UniversalPHP, - directoryPath: string + directoryPath: string, + zipPath?: string ) => { - const outputPath = `/tmp/file${Math.random()}.zip`; + const outputPath = zipPath || `/tmp/file${Math.random()}.zip`; const js = phpVars({ directoryPath, outputPath, @@ -107,6 +107,10 @@ export const zipDirectory = async ( `, }); + if (zipPath) { + return undefined; + } + const fileBuffer = await php.readFileAsBuffer(outputPath); php.unlink(outputPath); return fileBuffer; diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index 783a3400cb..26eb26d1c5 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -1,60 +1,60 @@ +import type { FilesystemOperation } from '@php-wasm/fs-journal'; +import { journalFSEvents, replayFSJournal } from '@php-wasm/fs-journal'; +import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; +import { setURLScope } from '@php-wasm/scopes'; +import { joinPaths, randomString } from '@php-wasm/util'; import type { GeneratedCertificate, - TCPOverFetchOptions, MountDevice, SyncProgressCallback, + TCPOverFetchOptions, } from '@php-wasm/web'; import { createDirectoryHandleMountHandler, exposeAPI, loadWebRuntime, } from '@php-wasm/web'; -import { setURLScope } from '@php-wasm/scopes'; -import { joinPaths } from '@php-wasm/util'; -import { wordPressSiteUrl } from './config'; +import { createMemoizedFetch } from '@wp-playground/common'; +import { directoryHandleFromMountDevice } from '@wp-playground/storage'; import { - getWordPressModuleDetails, - getSqliteDriverModuleDetails, LatestMinifiedWordPressVersion, LatestSqliteDriverVersion, MinifiedWordPressVersions, MinifiedWordPressVersionsList, + getSqliteDriverModuleDetails, + getWordPressModuleDetails, } from '@wp-playground/wordpress-builds'; -import { directoryHandleFromMountDevice } from '@wp-playground/storage'; -import { randomString } from '@php-wasm/util'; +import { wordPressSiteUrl } from './config'; import { - spawnHandlerFactory, backfillStaticFilesRemovedFromMinifiedBuild, hasCachedStaticFilesRemovedFromMinifiedBuild, + spawnHandlerFactory, } from './worker-utils'; -import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; -import { createMemoizedFetch } from '@wp-playground/common'; -import type { FilesystemOperation } from '@php-wasm/fs-journal'; -import { journalFSEvents, replayFSJournal } from '@php-wasm/fs-journal'; /* @ts-ignore */ import transportFetch from './playground-mu-plugin/playground-includes/wp_http_fetch.php?raw'; /* @ts-ignore */ import transportDummy from './playground-mu-plugin/playground-includes/wp_http_dummy.php?raw'; /* @ts-ignore */ -import playgroundWebMuPlugin from './playground-mu-plugin/0-playground.php?raw'; +import { logger } from '@php-wasm/logger'; import type { PHP, SupportedPHPVersion } from '@php-wasm/universal'; import { PHPResponse, PHPWorker, SupportedPHPVersionsList, } from '@php-wasm/universal'; +import { certificateToPEM, generateCertificate } from '@php-wasm/web'; import { bootWordPress, getFileNotFoundActionForWordPress, getLoadedWordPressVersion, } from '@wp-playground/wordpress'; import { wpVersionToStaticAssetsDirectory } from '@wp-playground/wordpress-builds'; -import { logger } from '@php-wasm/logger'; -import { generateCertificate, certificateToPEM } from '@php-wasm/web'; import { intlDisabledFunctions, networkingDisabledFunctions, } from './disabled-functions'; +/* @ts-ignore */ +import playgroundWebMuPlugin from './playground-mu-plugin/0-playground.php?raw'; import { WordPressFetchNetworkTransport } from './wordpress-fetch-network-transport'; // post message to parent diff --git a/packages/playground/storage/src/lib/browser-fs.ts b/packages/playground/storage/src/lib/browser-fs.ts index 889745b7ba..705a462504 100644 --- a/packages/playground/storage/src/lib/browser-fs.ts +++ b/packages/playground/storage/src/lib/browser-fs.ts @@ -1,7 +1,16 @@ -import type { MountDevice } from '@php-wasm/web'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import type * as pleaseLoadTypes from 'wicg-file-system-access'; +type MountDevice = + | { + type: 'opfs'; + path: string; + } + | { + type: 'local-fs'; + handle: FileSystemDirectoryHandle; + }; + export async function directoryHandleFromMountDevice( device: MountDevice ): Promise { diff --git a/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts b/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts index 5b4fa04e85..6d5fcdfa2a 100644 --- a/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts +++ b/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts @@ -7,6 +7,12 @@ SupportedPHPVersions.forEach((phpVersion: string) => { const cli = await runCLI({ command: 'server', php: phpVersion as any, + blueprint: { + version: 2, + siteOptions: { + blogname: 'My WordPress Website', + }, + }, }); try { // Make a request @@ -21,6 +27,6 @@ SupportedPHPVersions.forEach((phpVersion: string) => { } finally { await cli[Symbol.asyncDispose](); } - }, 10000); + }, 30000); }); }); diff --git a/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts b/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts index 6b60d172e2..2f432d91d5 100644 --- a/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts +++ b/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts @@ -1,8 +1,8 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { runCLI } from '@wp-playground/cli'; import type { SupportedPHPVersion } from '@php-wasm/universal'; -import { SupportedPHPVersions } from '@php-wasm/universal'; +import { printDebugDetails, SupportedPHPVersions } from '@php-wasm/universal'; +import { runCLI } from '@wp-playground/cli'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; const phpVersion = process.env.PHP_VERSION as SupportedPHPVersion; if (!phpVersion) { @@ -13,12 +13,17 @@ if (!SupportedPHPVersions.includes(phpVersion)) { } describe(`PHP ${phpVersion}`, () => { - it('Should load WordPress', { timeout: 10000 }, async () => { + it('Should load WordPress', { timeout: 30000 }, async () => { let cli; try { cli = await runCLI({ command: 'server', - php: phpVersion, + php: phpVersion as any, + blueprint: { + siteOptions: { + blogname: 'My WordPress Website', + }, + }, quiet: true, }); const response = await cli.playground.request({ @@ -31,6 +36,9 @@ describe(`PHP ${phpVersion}`, () => { response.text.includes(expectedText), `Response text does not include '${expectedText}'` ); + } catch (e) { + await printDebugDetails(e, (e as any)?.streamedResponse); + throw e; } finally { if (cli) { await cli[Symbol.asyncDispose](); diff --git a/packages/playground/website/vite.config.ts b/packages/playground/website/vite.config.ts index 21131377eb..51882278a3 100644 --- a/packages/playground/website/vite.config.ts +++ b/packages/playground/website/vite.config.ts @@ -63,6 +63,7 @@ export default defineConfig(({ command, mode }) => { port: websiteDevServerPort, host: websiteDevServerHost, proxy, + allowedHosts: ['playground-preview.test'], }, server: { diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index 6e2c006035..e26be58ecf 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -1,3 +1,4 @@ +import { logger } from '@php-wasm/logger'; import type { CookieStore, FileNotFoundAction, @@ -11,19 +12,19 @@ import { PHPRequestHandler, proxyFileSystem, rotatePHPRuntime, + sandboxedSpawnHandlerFactory, setPhpIniEntries, withPHPIniValues, writeFiles, } from '@php-wasm/universal'; +import { basename, dirname, joinPaths } from '@php-wasm/util'; import { preloadPhpInfoRoute, - setupPlatformLevelMuPlugins, preloadSqliteIntegration, + setupPlatformLevelMuPlugins, unzipWordPress, wordPressRewriteRules, } from '.'; -import { basename, dirname, joinPaths } from '@php-wasm/util'; -import { logger } from '@php-wasm/logger'; import { ensureWpConfig } from './rewrite-wp-config'; export type PhpIniOptions = Record; @@ -35,7 +36,7 @@ export interface Hooks { export type DatabaseType = 'sqlite' | 'mysql' | 'custom'; -export interface BootOptions { +export interface BootRequestHandlerOptions { createPhpRuntime: () => Promise; onPHPInstanceCreated?: (php: PHP) => Promise; /** @@ -58,12 +59,6 @@ export interface BootOptions { */ siteUrl: string; documentRoot?: string; - /** SQL file to load instead of installing WordPress. */ - dataSqlPath?: string; - /** Zip with the WordPress installation to extract in /wordpress. */ - wordPressZip?: File | Promise | undefined; - /** Preloaded SQLite integration plugin. */ - sqliteIntegrationPluginZip?: File | Promise; spawnHandler?: (processManager: PHPProcessManager) => SpawnHandler; /** * PHP.ini entries to define before running any code. They'll @@ -115,6 +110,23 @@ export interface BootOptions { cookieStore?: CookieStore | false; } +export interface BootOptions extends BootRequestHandlerOptions { + /** + * Mounting and Copying is handled via hooks for starters. + * + * In the future we could standardize the + * browser-specific and node-specific mounts + * in the future. + */ + hooks?: Hooks; + /** SQL file to load instead of installing WordPress. */ + dataSqlPath?: string; + /** Zip with the WordPress installation to extract in /wordpress. */ + wordPressZip?: File | Promise | undefined; + /** Preloaded SQLite integration plugin. */ + sqliteIntegrationPluginZip?: File | Promise; +} + /** * Boots a WordPress instance with the given options. * @@ -190,7 +202,8 @@ export async function bootWordPress(options: BootOptions) { return requestHandler; } -export async function bootRequestHandler(options: BootOptions) { +export async function bootRequestHandler(options: BootRequestHandlerOptions) { + const spawnHandler = options.spawnHandler ?? sandboxedSpawnHandlerFactory; async function createPhp( requestHandler: PHPRequestHandler, isPrimary: boolean @@ -234,9 +247,9 @@ export async function bootRequestHandler(options: BootOptions) { // Spawn handler is responsible for spawning processes for all the // `popen()`, `proc_open()` etc. calls. - if (options.spawnHandler) { + if (spawnHandler) { await php.setSpawnHandler( - options.spawnHandler(requestHandler.processManager) + spawnHandler(requestHandler.processManager) ); } diff --git a/packages/vite-extensions/vite-external-modules.ts b/packages/vite-extensions/vite-external-modules.ts index 2f3a91967a..665a838b7d 100644 --- a/packages/vite-extensions/vite-external-modules.ts +++ b/packages/vite-extensions/vite-external-modules.ts @@ -12,6 +12,9 @@ export const getExternalModules = () => { 'os', 'net', 'fs', + 'fs/promises', + 'node:fs', + 'node:fs/promises', 'fs-extra', 'path', 'child_process', @@ -21,6 +24,9 @@ export const getExternalModules = () => { 'util', 'dns', 'ws', + 'readline', + 'worker_threads', + 'url', /^@php-wasm\//, /^@wp-playground\//, ...deps, diff --git a/packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts b/packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts index c62ef96636..6b18a7127c 100644 --- a/packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts +++ b/packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts @@ -61,6 +61,8 @@ const patternsToNotCache = [ /^\/assets\/php_.*\.js$/, // PHP JS files /^\/assets\/wp-.*\.zip$/, // Minified WordPress builds and static assets bundles /^\/assets\/sqlite-database-integration-[\w]+\.zip/, // SQLite plugin + /^\/assets\/blueprints.*\.phar$/, // Blueprints v2 runner. It isn't used by the Playground website yet, + // only by the CLI. ]; function listFiles(dirPath: string, fileList: string[] = []) {