├── .ddev ├── commands │ └── web │ │ └── make └── config.yaml ├── .github ├── actions │ └── create-drupal-core-artifact │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── drupal_core_artifact.yml │ ├── web_build.yml │ └── web_deploy.yml ├── .gitignore ├── .nvmrc ├── Caddyfile ├── LICENSE ├── Makefile ├── README.md ├── docs └── testing-drupal-build.md ├── package-lock.json ├── package.json ├── patches ├── .gitattributes ├── default.settings.php ├── npm │ └── php-cgi-wasm+0.0.9-alpha-25.patch ├── renderer-remove-fibers.patch ├── sqlite-file_system-service.patch └── system-reqs-wasm.patch ├── postinstall.sh ├── public ├── assets │ ├── export.phpcode │ ├── init.phpcode │ ├── install-site.phpcode │ └── login-admin.phpcode ├── cms.html ├── cookie-map.mjs ├── drupal-cgi-worker.mjs ├── drupal.html ├── favicon.ico ├── index.html ├── install-worker.mjs ├── main.mjs ├── noto-sans.woff2 ├── service-worker.mjs ├── trial-manager.mjs └── utils.mjs ├── src └── styles.css ├── tailwind.config.js ├── tests ├── fixtures │ └── .gitignore ├── init-phpcode.test.js ├── install-site-phpcode.test.js ├── install-worker.test.js ├── trial-manager.test.js └── utils.js ├── vitest.config.mts └── workers.webpack.js /.ddev/commands/web/make: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## Description: Run make commands 4 | ## Usage: make 5 | ## Example: ddev make build 6 | 7 | make $@ 8 | -------------------------------------------------------------------------------- /.ddev/config.yaml: -------------------------------------------------------------------------------- 1 | name: wasm-drupal 2 | type: php 3 | docroot: "public" 4 | php_version: "8.2" 5 | webserver_type: nginx-fpm 6 | xdebug_enabled: false 7 | additional_hostnames: [] 8 | additional_fqdns: [] 9 | use_dns_when_possible: true 10 | composer_version: "2" 11 | web_environment: [] 12 | corepack_enable: false 13 | omit_containers: [db] 14 | webimage_extra_packages: [build-essential] 15 | 16 | # Key features of DDEV's config.yaml: 17 | 18 | # name: # Name of the project, automatically provides 19 | # http://projectname.ddev.site and https://projectname.ddev.site 20 | 21 | # type: # backdrop, craftcms, django4, drupal, drupal6, drupal7, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress 22 | # See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more 23 | # information on the different project types 24 | # "drupal" covers recent Drupal 8+ 25 | 26 | # docroot: # Relative path to the directory containing index.php. 27 | 28 | # php_version: "8.2" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3" 29 | 30 | # You can explicitly specify the webimage but this 31 | # is not recommended, as the images are often closely tied to DDEV's' behavior, 32 | # so this can break upgrades. 33 | 34 | # webimage: # nginx/php docker image. 35 | # database: 36 | # type: # mysql, mariadb, postgres 37 | # version: # database version, like "10.11" or "8.0" 38 | # MariaDB versions can be 5.5-10.8 and 10.11, MySQL versions can be 5.5-8.0 39 | # PostgreSQL versions can be 9-16. 40 | 41 | # router_http_port: # Port to be used for http (defaults to global configuration, usually 80) 42 | # router_https_port: # Port for https (defaults to global configuration, usually 443) 43 | 44 | # xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart" 45 | # Note that for most people the commands 46 | # "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, 47 | # as leaving Xdebug enabled all the time is a big performance hit. 48 | 49 | # xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart" 50 | # Note that for most people the commands 51 | # "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better, 52 | # as leaving Xhprof enabled all the time is a big performance hit. 53 | 54 | # webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn 55 | 56 | # timezone: Europe/Berlin 57 | # This is the timezone used in the containers and by PHP; 58 | # it can be set to any valid timezone, 59 | # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 60 | # For example Europe/Dublin or MST7MDT 61 | 62 | # composer_root: 63 | # Relative path to the Composer root directory from the project root. This is 64 | # the directory which contains the composer.json and where all Composer related 65 | # commands are executed. 66 | 67 | # composer_version: "2" 68 | # You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 69 | # to use the latest major version available at the time your container is built. 70 | # It is also possible to use each other Composer version channel. This includes: 71 | # - 2.2 (latest Composer LTS version) 72 | # - stable 73 | # - preview 74 | # - snapshot 75 | # Alternatively, an explicit Composer version may be specified, for example "2.2.18". 76 | # To reinstall Composer after the image was built, run "ddev debug refresh". 77 | 78 | # nodejs_version: "20" 79 | # change from the default system Node.js version to any other version. 80 | # Numeric version numbers can be complete (i.e. 18.15.0) or 81 | # incomplete (18, 17.2, 16). 'lts' and 'latest' can be used as well along with 82 | # other named releases. 83 | # see https://www.npmjs.com/package/n#specifying-nodejs-versions 84 | # Note that you can continue using 'ddev nvm' or nvm inside the web container 85 | # to change the project's installed node version if you need to. 86 | 87 | # corepack_enable: false 88 | # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm 89 | 90 | # additional_hostnames: 91 | # - somename 92 | # - someothername 93 | # would provide http and https URLs for "somename.ddev.site" 94 | # and "someothername.ddev.site". 95 | 96 | # additional_fqdns: 97 | # - example.com 98 | # - sub1.example.com 99 | # would provide http and https URLs for "example.com" and "sub1.example.com" 100 | # Please take care with this because it can cause great confusion. 101 | 102 | # upload_dirs: "custom/upload/dir" 103 | # 104 | # upload_dirs: 105 | # - custom/upload/dir 106 | # - ../private 107 | # 108 | # would set the destination paths for ddev import-files to /custom/upload/dir 109 | # When Mutagen is enabled this path is bind-mounted so that all the files 110 | # in the upload_dirs don't have to be synced into Mutagen. 111 | 112 | # disable_upload_dirs_warning: false 113 | # If true, turns off the normal warning that says 114 | # "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set" 115 | 116 | # ddev_version_constraint: "" 117 | # Example: 118 | # ddev_version_constraint: ">= 1.22.4" 119 | # This will enforce that the running ddev version is within this constraint. 120 | # See https://github.com/Masterminds/semver#checking-version-constraints for 121 | # supported constraint formats 122 | 123 | # working_dir: 124 | # web: /var/www/html 125 | # db: /home 126 | # would set the default working directory for the web and db services. 127 | # These values specify the destination directory for ddev ssh and the 128 | # directory in which commands passed into ddev exec are run. 129 | 130 | # omit_containers: [db, ddev-ssh-agent] 131 | # Currently only these containers are supported. Some containers can also be 132 | # omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit 133 | # the "db" container, several standard features of DDEV that access the 134 | # database container will be unusable. In the global configuration it is also 135 | # possible to omit ddev-router, but not here. 136 | 137 | # performance_mode: "global" 138 | # DDEV offers performance optimization strategies to improve the filesystem 139 | # performance depending on your host system. Should be configured globally. 140 | # 141 | # If set, will override the global config. Possible values are: 142 | # - "global": uses the value from the global config. 143 | # - "none": disables performance optimization for this project. 144 | # - "mutagen": enables Mutagen for this project. 145 | # - "nfs": enables NFS for this project. 146 | # 147 | # See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs 148 | # See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen 149 | 150 | # fail_on_hook_fail: False 151 | # Decide whether 'ddev start' should be interrupted by a failing hook 152 | 153 | # host_https_port: "59002" 154 | # The host port binding for https can be explicitly specified. It is 155 | # dynamic unless otherwise specified. 156 | # This is not used by most people, most people use the *router* instead 157 | # of the localhost port. 158 | 159 | # host_webserver_port: "59001" 160 | # The host port binding for the ddev-webserver can be explicitly specified. It is 161 | # dynamic unless otherwise specified. 162 | # This is not used by most people, most people use the *router* instead 163 | # of the localhost port. 164 | 165 | # host_db_port: "59002" 166 | # The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic 167 | # unless explicitly specified. 168 | 169 | # mailpit_http_port: "8025" 170 | # mailpit_https_port: "8026" 171 | # The Mailpit ports can be changed from the default 8025 and 8026 172 | 173 | # host_mailpit_port: "8025" 174 | # The mailpit port is not normally bound on the host at all, instead being routed 175 | # through ddev-router, but it can be bound directly to localhost if specified here. 176 | 177 | # webimage_extra_packages: [php7.4-tidy, php-bcmath] 178 | # Extra Debian packages that are needed in the webimage can be added here 179 | 180 | # dbimage_extra_packages: [telnet,netcat] 181 | # Extra Debian packages that are needed in the dbimage can be added here 182 | 183 | # use_dns_when_possible: true 184 | # If the host has internet access and the domain configured can 185 | # successfully be looked up, DNS will be used for hostname resolution 186 | # instead of editing /etc/hosts 187 | # Defaults to true 188 | 189 | # project_tld: ddev.site 190 | # The top-level domain used for project URLs 191 | # The default "ddev.site" allows DNS lookup via a wildcard 192 | # If you prefer you can change this to "ddev.local" to preserve 193 | # pre-v1.9 behavior. 194 | 195 | # ngrok_args: --basic-auth username:pass1234 196 | # Provide extra flags to the "ngrok http" command, see 197 | # https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h" 198 | 199 | # disable_settings_management: false 200 | # If true, DDEV will not create CMS-specific settings files like 201 | # Drupal's settings.php/settings.ddev.php or TYPO3's additional.php 202 | # In this case the user must provide all such settings. 203 | 204 | # You can inject environment variables into the web container with: 205 | # web_environment: 206 | # - SOMEENV=somevalue 207 | # - SOMEOTHERENV=someothervalue 208 | 209 | # no_project_mount: false 210 | # (Experimental) If true, DDEV will not mount the project into the web container; 211 | # the user is responsible for mounting it manually or via a script. 212 | # This is to enable experimentation with alternate file mounting strategies. 213 | # For advanced users only! 214 | 215 | # bind_all_interfaces: false 216 | # If true, host ports will be bound on all network interfaces, 217 | # not the localhost interface only. This means that ports 218 | # will be available on the local network if the host firewall 219 | # allows it. 220 | 221 | # default_container_timeout: 120 222 | # The default time that DDEV waits for all containers to become ready can be increased from 223 | # the default 120. This helps in importing huge databases, for example. 224 | 225 | #web_extra_exposed_ports: 226 | #- name: nodejs 227 | # container_port: 3000 228 | # http_port: 2999 229 | # https_port: 3000 230 | #- name: something 231 | # container_port: 4000 232 | # https_port: 4000 233 | # http_port: 3999 234 | # Allows a set of extra ports to be exposed via ddev-router 235 | # Fill in all three fields even if you don’t intend to use the https_port! 236 | # If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start. 237 | # 238 | # The port behavior on the ddev-webserver must be arranged separately, for example 239 | # using web_extra_daemons. 240 | # For example, with a web app on port 3000 inside the container, this config would 241 | # expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 242 | # web_extra_exposed_ports: 243 | # - name: myapp 244 | # container_port: 3000 245 | # http_port: 9998 246 | # https_port: 9999 247 | 248 | #web_extra_daemons: 249 | #- name: "http-1" 250 | # command: "/var/www/html/node_modules/.bin/http-server -p 3000" 251 | # directory: /var/www/html 252 | #- name: "http-2" 253 | # command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" 254 | # directory: /var/www/html 255 | 256 | # override_config: false 257 | # By default, config.*.yaml files are *merged* into the configuration 258 | # But this means that some things can't be overridden 259 | # For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge 260 | # and you can't erase existing hooks or all environment variables. 261 | # However, with "override_config: true" in a particular config.*.yaml file, 262 | # 'use_dns_when_possible: false' can override the existing values, and 263 | # hooks: 264 | # post-start: [] 265 | # or 266 | # web_environment: [] 267 | # or 268 | # additional_hostnames: [] 269 | # can have their intended affect. 'override_config' affects only behavior of the 270 | # config.*.yaml file it exists in. 271 | 272 | # Many DDEV commands can be extended to run tasks before or after the 273 | # DDEV command is executed, for example "post-start", "post-import-db", 274 | # "pre-composer", "post-composer" 275 | # See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more 276 | # information on the commands that can be extended and the tasks you can define 277 | # for them. Example: 278 | #hooks: 279 | -------------------------------------------------------------------------------- /.github/actions/create-drupal-core-artifact/action.yml: -------------------------------------------------------------------------------- 1 | name: Creates Drupal Core artifact 2 | description: Creates a Drupal Core artifact 3 | inputs: 4 | artifact-name: 5 | description: 'The name of the artifact to create' 6 | required: true 7 | default: 'drupal-core' 8 | artifact-file: 9 | description: 'The artifact file to create' 10 | required: true 11 | default: 'drupal-core' 12 | artifact-version: 13 | description: 'The version of the artifact to create' 14 | required: true 15 | default: '^10' 16 | runs: 17 | using: 'composite' 18 | steps: 19 | - name: "Install PHP" 20 | uses: "shivammathur/setup-php@v2" 21 | with: 22 | coverage: "none" 23 | php-version: "8.3" 24 | - name: composer create-project 25 | shell: bash 26 | run: | 27 | composer create-project drupal/recommended-project:"$INPUT_ARTIFACT_VERSION" "$INPUT_ARTIFACT_NAME" --no-install 28 | env: 29 | INPUT_ARTIFACT_VERSION: ${{ inputs.artifact-version }} 30 | INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }} 31 | - name: process build 32 | shell: bash 33 | run: | 34 | cd "$INPUT_ARTIFACT_NAME" 35 | composer config --merge --json 'extra.drupal-scaffold.file-mapping' '{"[project-root]/.gitattributes": {"append": "../patches/.gitattributes"}}' 36 | composer config --merge --json 'extra.drupal-scaffold.file-mapping' '{"[web-root]/sites/default/default.settings.php": {"append": "../patches/default.settings.php"}}' 37 | composer config --merge --json 'extra.patches.drupal/core' '{"Remove Fibers from Renderer": "../patches/renderer-remove-fibers.patch"}' 38 | composer config --merge --json 'extra.patches.drupal/core' '{"Remove file_system service usage from SQLite installer": "../patches/sqlite-file_system-service.patch"}' 39 | composer config --merge --json 'extra.patches.drupal/core' '{"Reqs to OK": "../patches/system-reqs-wasm.patch"}' 40 | 41 | composer require cweagans/composer-patches --no-install 42 | composer config --no-plugins allow-plugins.cweagans/composer-patches true 43 | 44 | composer install --no-dev 45 | env: 46 | INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }} 47 | - name: composer archive 48 | shell: bash 49 | run: cd "$INPUT_ARTIFACT_NAME" && composer archive --format=zip --file="$INPUT_ARTIFACT_FILE" 50 | env: 51 | INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }} 52 | INPUT_ARTIFACT_FILE: ${{ inputs.artifact-file }} -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/drupal_core_artifact.yml: -------------------------------------------------------------------------------- 1 | name: "Drupal Core Artifact" 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | concurrency: artifacts 7 | jobs: 8 | build: 9 | name: "Build drupal-core" 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - name: "Checkout" 13 | uses: actions/checkout@v4 14 | - uses: ./.github/actions/create-drupal-core-artifact 15 | with: 16 | artifact-name: "drupal-core" 17 | artifact-file: "drupal-core" 18 | artifact-version: "^10" 19 | - name: Upload prototype archive 20 | uses: actions/upload-artifact@v4 21 | with: 22 | name: drupal-core 23 | path: | 24 | drupal-core/drupal-core.zip 25 | - name: "configure AWS" 26 | uses: aws-actions/configure-aws-credentials@v4 27 | with: 28 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 29 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 30 | aws-region: us-east-1 31 | - name: Upload artifact 32 | run: aws s3 cp drupal-core/drupal-core.zip s3://wasm-drupal/assets/drupal-core.zip 33 | - name: Invalidate CloudFront 34 | run: aws cloudfront create-invalidation --distribution-id ENEVO72R9T6H5 --paths "/assets/drupal-core.zip" 35 | -------------------------------------------------------------------------------- /.github/workflows/web_build.yml: -------------------------------------------------------------------------------- 1 | name: Web build 2 | on: 3 | workflow_call: 4 | pull_request: 5 | branches: 6 | - main 7 | jobs: 8 | web_build: 9 | name: "Build artifact" 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - name: "Checkout" 13 | uses: actions/checkout@v4 14 | - uses: ./.github/actions/create-drupal-core-artifact 15 | - run: cp drupal-core/drupal-core.zip tests/fixtures 16 | - run: mv drupal-core tests/fixtures 17 | 18 | - name: "Install Node" 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: "22" 22 | 23 | - run: npm ci 24 | - run: npm run test 25 | - run: npm run build 26 | 27 | - name: Upload demo archive 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: demo 31 | path: public 32 | -------------------------------------------------------------------------------- /.github/workflows/web_deploy.yml: -------------------------------------------------------------------------------- 1 | name: "Deploy" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | concurrency: web_deploy 7 | jobs: 8 | build: 9 | uses: ./.github/workflows/web_build.yml 10 | deploy: 11 | name: "Build & deploy" 12 | runs-on: "ubuntu-latest" 13 | needs: [build] 14 | steps: 15 | - name: Download Drupal artifact 16 | uses: actions/download-artifact@v4 17 | with: 18 | name: demo 19 | - name: "configure AWS" 20 | uses: aws-actions/configure-aws-credentials@v4 21 | with: 22 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 23 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 24 | aws-region: us-east-1 25 | - name: Deploy 26 | run: aws s3 sync ./ s3://wasm-drupal 27 | - name: Invalidate CloudFront 28 | run: aws cloudfront create-invalidation --distribution-id ENEVO72R9T6H5 --paths "/*" 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | /public/*.so 3 | /public/*.wasm 4 | /public/php-* 5 | /public/Php* 6 | /public/breakoutRequest.mjs 7 | /public/config.mjs 8 | /public/fsOps.mjs 9 | /public/msg-bus.mjs 10 | /public/parseResponse.mjs 11 | /public/resolveDependencies.mjs 12 | /public/webTransactions.mjs 13 | /public/OutputBuffer.mjs 14 | /public/_Event.mjs 15 | /public/service-worker.js 16 | /public/install-worker.js 17 | /public/worker.js.map 18 | /node_modules 19 | public/styles.css 20 | 21 | /drupal-core 22 | /drupal-cms 23 | /starshot-prototype 24 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.4.0 2 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | :80 { 2 | root * /usr/share/caddy 3 | encode gzip 4 | try_files {path} /index.html 5 | file_server 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matt Glaman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PWD = $(shell pwd) 2 | 3 | build: download-artifacts demo-build 4 | 5 | serve: 6 | docker run --rm -p 80:80 \ 7 | -v ${PWD}/Caddyfile:/etc/caddy/Caddyfile \ 8 | -v ${PWD}/public:/usr/share/caddy \ 9 | caddy 10 | 11 | download-artifacts: 12 | curl -o public/assets/drupal-core.zip https://wasm-drupal.mglaman.dev/assets/drupal-core.zip 13 | 14 | demo-build: 15 | npm install 16 | npm run build 17 | 18 | demo-test: 19 | npm run test 20 | 21 | clean: 22 | cd public && git clean -fdx 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Drupal on the Edge with Web Assembly 2 | 3 | Run Drupal on the edge in your browser with [Web Assembly](https://webassembly.org/). 4 | 5 | This utilizes [php-wasm](https://github.com/seanmorris/php-wasm) and [php-cgi-wasm](https://github.com/seanmorris/php-wasm/tree/master/packages/php-cgi-wasm) by [Sean Morris](https://github.com/seanmorris). 6 | 7 | Want to learn more? Catch my session [Running Drupal on the Edge with Web Assembly](https://events.drupal.org/barcelona2024/session/running-drupal-edge-web-assembly) at DrupalCon Barcelona. 8 | 9 | ## How it works 10 | 11 | PHP has been compiled into Web Assembly. 12 | 13 | * The `php-wasm` package allows us to execute PHP code in the browser. 14 | * The `php-cgi-wasm` package allows us to execute PHP code in a service worker, which emulates CGI (think PHP-FPM), and allows serving requests to and from Drupal. 15 | 16 | ## Running locally 17 | 18 | ### Docker with DDEV 19 | 20 | Using [DDEV](https://ddev.com/) you can build and run the playground locally. 21 | 22 | ```sh 23 | ddev start 24 | ddev make build 25 | ``` 26 | 27 | Visit https://wasm-drupal.ddev.site 28 | 29 | ### Without Docker 30 | 31 | Currently this requires NPM, PHP (with Composer) on the host machine and Docker. 32 | 33 | ```shell 34 | make build 35 | 36 | make serve 37 | ``` 38 | 39 | Open `http://localhost` 40 | 41 | Click **Install** for either Drupal or the Starshot prototype, and then wait for environment to launch. 42 | 43 | Log in with 44 | 45 | * Username: `admin` 46 | * Password: `admin` 47 | 48 | ## Upcoming: drupal-was package 49 | 50 | The goal is to split out the JavaScript from this project into a reusable package for others. 51 | 52 | * `setup-cgi-worker.mjs` as an easy way to register the service worker for serving a Drupal application 53 | * `install-worker.mjs` as an easy way to install a Drupal application from an artifact 54 | 55 | ## Debugging steps 56 | 57 | ### Clearing all data 58 | 59 | Use your browser's "Clear site data" functionality to perform a manual reset. 60 | 61 | ### Debugging the service worker 62 | 63 | Visit [chrome://serviceworker-internals/](chrome://serviceworker-internals/) 64 | 65 | ## Limitations 66 | 67 | * php-wasm does not provide an exposed function for running a specific script file, that means scripts like Composer and Drush cannot be directly invoked. 68 | * php-wasm's SAPI name is `embed`, which breaks any code which checks `PHP_SAPI === 'cli'`, such as Drush. 69 | 70 | ## Next steps 71 | 72 | - [ ] Allow exporting Drupal database to use locally, with DDEV. [#10](https://github.com/mglaman/wasm-drupal/issues/10) 73 | - [ ] Allow exporting Drupal codebase to use locally, with DDEV. [#11](https://github.com/mglaman/wasm-drupal/issues/11) 74 | -------------------------------------------------------------------------------- /docs/testing-drupal-build.md: -------------------------------------------------------------------------------- 1 | # Testing a Drupal build 2 | 3 | 1. Make changes in `drupal-src` (manually or via patches) 4 | 2. Run `make drupal-archive` to run `composer install` and create artifact 5 | 3. Run `copy-playground-archive` to make new artifact available 6 | 4. Run `make serve` to run playground 7 | 5. Install and test 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@tailwindcss/typography": "^0.5.13", 4 | "@vitest/coverage-v8": "^2.0.5", 5 | "@vitest/web-worker": "^2.0.5", 6 | "babel-loader": "^9.1.3", 7 | "happy-dom": "^14.12.3", 8 | "patch-package": "^8.0.0", 9 | "php-cgi-wasm": "0.0.9-alpha-25", 10 | "php-wasm": "0.0.9-alpha-25", 11 | "php-wasm-dom": "0.0.9-alpha-25", 12 | "php-wasm-gd": "0.0.9-alpha-25", 13 | "php-wasm-iconv": "0.0.9-alpha-25", 14 | "php-wasm-libxml": "0.0.9-alpha-25", 15 | "php-wasm-libzip": "0.0.9-alpha-25", 16 | "php-wasm-mbstring": "0.0.9-alpha-25", 17 | "php-wasm-simplexml": "0.0.9-alpha-25", 18 | "php-wasm-sqlite": "0.0.9-alpha-25", 19 | "php-wasm-xml": "0.0.9-alpha-25", 20 | "php-wasm-zlib": "0.0.9-alpha-25", 21 | "tailwindcss": "^3.4.4", 22 | "vitest": "^2.0.5", 23 | "vitest-fetch-mock": "^0.3.0", 24 | "webpack": "^5.92.1", 25 | "webpack-cli": "^5.1.4", 26 | "webpack-dev-server": "^5.0.4" 27 | }, 28 | "scripts": { 29 | "tailwind:build": "npx tailwindcss -i ./src/styles.css -o ./public/styles.css --minify", 30 | "tailwind:watch": "npx tailwindcss -i ./src/styles.css -o ./public/styles.css --watch", 31 | "postinstall": "patch-package --patch-dir=patches/npm && ./postinstall.sh", 32 | "worker:build": "webpack --config workers.webpack.js", 33 | "build": "npm run worker:build && npm run tailwind:build", 34 | "test": "vitest", 35 | "coverage": "vitest run --coverage" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /patches/.gitattributes: -------------------------------------------------------------------------------- 1 | patches export-ignore 2 | vendor/bin export-ignore 3 | web/core/tests/**/* export-ignore 4 | web/core/modules/*/tests/**/* export-ignore 5 | web/core/themes/*/tests/**/* export-ignore 6 | web/modules/*/tests/**/* export-ignore 7 | web/sites/default/files/php export-ignore 8 | web/core/assets/scaffold export-ignore 9 | 10 | vendor/*/.github/**/* export-ignore 11 | vendor/*/*.md export-ignore 12 | vendor/*/*.dist export-ignore 13 | vendor/*/*.lock export-ignore 14 | vendor/*/tests/**/* export-ignore 15 | 16 | # For starshot prototype 17 | tests export-ignore 18 | web/**/*.md export-ignore 19 | .ddev export-ignore 20 | -------------------------------------------------------------------------------- /patches/default.settings.php: -------------------------------------------------------------------------------- 1 | $settings["skip_permissions_hardening"] = TRUE; 2 | $config['system.performance']['css']['preprocess'] = FALSE; 3 | $config['system.performance']['js']['preprocess'] = FALSE; 4 | -------------------------------------------------------------------------------- /patches/npm/php-cgi-wasm+0.0.9-alpha-25.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/php-cgi-wasm/PhpCgiBase.mjs b/node_modules/php-cgi-wasm/PhpCgiBase.mjs 2 | index b5d9fbb..01f7b90 100644 3 | --- a/node_modules/php-cgi-wasm/PhpCgiBase.mjs 4 | +++ b/node_modules/php-cgi-wasm/PhpCgiBase.mjs 5 | @@ -406,7 +406,7 @@ export class PhpCgiBase 6 | else 7 | { 8 | 9 | - path = docroot + '/' + rewrite.substr((vHostPrefix || this.prefix).length); 10 | + path = docroot + '/' + rewrite.substr((vHostPrefix || this.prefix).length).replace(/^\/+/, ''); 11 | scriptName = path; 12 | } 13 | 14 | @@ -419,6 +419,15 @@ export class PhpCgiBase 15 | 16 | const extension = path.split('.').pop(); 17 | 18 | + if(vHostEntrypoint) 19 | + { 20 | + if (extension === 'php') { 21 | + scriptName = vHostPrefix + '/' + rewrite.substr((vHostPrefix || this.prefix).length).replace(/^\/+/, '') 22 | + } else { 23 | + scriptName = vHostPrefix + '/' + vHostEntrypoint; 24 | + } 25 | + } 26 | + 27 | if(extension !== 'php' && extension !== 'phar') 28 | { 29 | const aboutPath = php.FS.analyzePath(path); 30 | -------------------------------------------------------------------------------- /patches/renderer-remove-fibers.patch: -------------------------------------------------------------------------------- 1 | diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php 2 | index 3774cb2272..e2ab5ad075 100644 3 | --- a/core/lib/Drupal/Core/Render/Renderer.php 4 | +++ b/core/lib/Drupal/Core/Render/Renderer.php 5 | @@ -179,11 +179,26 @@ protected function doReplacePlaceholder(string $placeholder, string|MarkupInterf 6 | */ 7 | public function renderPlaceholder($placeholder, array $elements) { 8 | // Get the render array for the given placeholder 9 | - $placeholder_element = $elements['#attached']['placeholders'][$placeholder]; 10 | - $markup = $this->doRenderPlaceholder($placeholder_element); 11 | - return $this->doReplacePlaceholder($placeholder, $markup, $elements, $placeholder_element); 12 | + $placeholder_elements = $elements['#attached']['placeholders'][$placeholder]; 13 | + 14 | + // Prevent the render array from being auto-placeholdered again. 15 | + $placeholder_elements['#create_placeholder'] = FALSE; 16 | + 17 | + // Render the placeholder into markup. 18 | + $markup = $this->renderInIsolation($placeholder_elements); 19 | + 20 | + // Replace the placeholder with its rendered markup, and merge its 21 | + // bubbleable metadata with the main elements'. 22 | + $elements['#markup'] = Markup::create(str_replace($placeholder, $markup, $elements['#markup'])); 23 | + $elements = $this->mergeBubbleableMetadata($elements, $placeholder_elements); 24 | + 25 | + // Remove the placeholder that we've just rendered. 26 | + unset($elements['#attached']['placeholders'][$placeholder]); 27 | + 28 | + return $elements; 29 | } 30 | 31 | + 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | @@ -642,7 +657,7 @@ protected function setCurrentRenderContext(RenderContext $context = NULL) { 36 | * bubbleable metadata associated with the markup that replaced the 37 | * placeholders. 38 | * 39 | - * @return bool 40 | + * @returns bool 41 | * Whether placeholders were replaced. 42 | * 43 | * @see \Drupal\Core\Render\Renderer::renderPlaceholder() 44 | @@ -667,47 +682,13 @@ protected function replacePlaceholders(array &$elements) { 45 | 46 | // First render all placeholders except 'status messages' placeholders. 47 | $message_placeholders = []; 48 | - $fibers = []; 49 | foreach ($elements['#attached']['placeholders'] as $placeholder => $placeholder_element) { 50 | if (isset($placeholder_element['#lazy_builder']) && $placeholder_element['#lazy_builder'][0] === 'Drupal\Core\Render\Element\StatusMessages::renderMessages') { 51 | $message_placeholders[] = $placeholder; 52 | } 53 | else { 54 | - // Get the render array for the given placeholder 55 | - $fibers[$placeholder] = new \Fiber(function () use ($placeholder_element) { 56 | - return [$this->doRenderPlaceholder($placeholder_element), $placeholder_element]; 57 | - }); 58 | - } 59 | - } 60 | - $iterations = 0; 61 | - while (count($fibers) > 0) { 62 | - foreach ($fibers as $placeholder => $fiber) { 63 | - if (!$fiber->isStarted()) { 64 | - $fiber->start(); 65 | - } 66 | - elseif ($fiber->isSuspended()) { 67 | - $fiber->resume(); 68 | - } 69 | - // If the Fiber hasn't terminated by this point, move onto the next 70 | - // placeholder, we'll resume this fiber again when we get back here. 71 | - if (!$fiber->isTerminated()) { 72 | - // If we've gone through the placeholders once already, and they're 73 | - // still not finished, then start to allow code higher up the stack to 74 | - // get on with something else. 75 | - if ($iterations) { 76 | - $fiber = \Fiber::getCurrent(); 77 | - if ($fiber !== NULL) { 78 | - $fiber->suspend(); 79 | - } 80 | - } 81 | - continue; 82 | - } 83 | - [$markup, $placeholder_element] = $fiber->getReturn(); 84 | - 85 | - $elements = $this->doReplacePlaceholder($placeholder, $markup, $elements, $placeholder_element); 86 | - unset($fibers[$placeholder]); 87 | + $elements = $this->renderPlaceholder($placeholder, $elements); 88 | } 89 | - $iterations++; 90 | } 91 | 92 | // Then render 'status messages' placeholders. 93 | -------------------------------------------------------------------------------- /patches/sqlite-file_system-service.patch: -------------------------------------------------------------------------------- 1 | From a47be591116439ec5c9e5cf06a68bcc4285ab7f0 Mon Sep 17 00:00:00 2001 2 | From: Matt Glaman 3 | Date: Wed, 24 Jul 2024 10:10:17 -0500 4 | Subject: [PATCH 1/3] use tempnam directly 5 | 6 | --- 7 | .../sqlite/src/Driver/Database/sqlite/Install/Tasks.php | 4 +--- 8 | 1 file changed, 1 insertion(+), 3 deletions(-) 9 | 10 | diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php b/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php 11 | index 050dd2c3ceb6..a896bfcb1d2a 100644 12 | --- a/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php 13 | +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php 14 | @@ -74,9 +74,7 @@ protected function connect() { 15 | $connection_info = Database::getConnectionInfo(); 16 | $database = $connection_info['default']['database']; 17 | 18 | - // We cannot use \Drupal::service('file_system')->getTempDirectory() 19 | - // here because we haven't yet successfully connected to the database. 20 | - $connection_info['default']['database'] = \Drupal::service('file_system')->tempnam(sys_get_temp_dir(), 'sqlite'); 21 | + $connection_info['default']['database'] = tempnam(sys_get_temp_dir(), 'sqlite'); 22 | 23 | // In order to change the Database::$databaseInfo array, need to remove 24 | // the active connection, then re-add it with the new info. 25 | -- 26 | GitLab 27 | 28 | 29 | From 722aa6bdcda260a2f74b929b046d513a584d4546 Mon Sep 17 00:00:00 2001 30 | From: Matt Glaman 31 | Date: Wed, 24 Jul 2024 10:46:28 -0500 32 | Subject: [PATCH 2/3] register file_system 33 | 34 | --- 35 | core/includes/install.core.inc | 8 ++++++++ 36 | .../sqlite/src/Driver/Database/sqlite/Install/Tasks.php | 4 +++- 37 | 2 files changed, 11 insertions(+), 1 deletion(-) 38 | 39 | diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc 40 | index 68ef6a2c81a2..2cfb092bb14f 100644 41 | --- a/core/includes/install.core.inc 42 | +++ b/core/includes/install.core.inc 43 | @@ -17,6 +17,7 @@ 44 | use Drupal\Core\Database\Database; 45 | use Drupal\Core\Database\DatabaseExceptionWrapper; 46 | use Drupal\Core\Extension\Exception\UnknownExtensionException; 47 | +use Drupal\Core\File\FileSystem; 48 | use Drupal\Core\File\FileSystemInterface; 49 | use Drupal\Core\Form\FormState; 50 | use Drupal\Core\Installer\Exception\AlreadyInstalledException; 51 | @@ -29,6 +30,7 @@ 52 | use Drupal\Core\Recipe\Recipe; 53 | use Drupal\Core\Recipe\RecipeRunner; 54 | use Drupal\Core\Site\Settings; 55 | +use Drupal\Core\StreamWrapper\StreamWrapperManager; 56 | use Drupal\Core\StringTranslation\Translator\FileTranslation; 57 | use Drupal\Core\StackMiddleware\ReverseProxyMiddleware; 58 | use Drupal\Core\Extension\ExtensionDiscovery; 59 | @@ -378,6 +380,12 @@ function install_begin_request($class_loader, &$install_state) { 60 | // @see \Drupal\Core\Extension\DatabaseDriverList 61 | $container->set('class_loader', $class_loader); 62 | 63 | + $container->set('settings', Settings::getInstance()); 64 | + $container->register('stream_wrapper_manager', StreamWrapperManager::class); 65 | + $container->register('file_system', FileSystem::class) 66 | + ->addArgument(new Reference('stream_wrapper_manager')) 67 | + ->addArgument(new Reference('settings')); 68 | + 69 | \Drupal::setContainer($container); 70 | 71 | // Determine whether base system services are ready to operate. 72 | diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php b/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php 73 | index a896bfcb1d2a..050dd2c3ceb6 100644 74 | --- a/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php 75 | +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Install/Tasks.php 76 | @@ -74,7 +74,9 @@ protected function connect() { 77 | $connection_info = Database::getConnectionInfo(); 78 | $database = $connection_info['default']['database']; 79 | 80 | - $connection_info['default']['database'] = tempnam(sys_get_temp_dir(), 'sqlite'); 81 | + // We cannot use \Drupal::service('file_system')->getTempDirectory() 82 | + // here because we haven't yet successfully connected to the database. 83 | + $connection_info['default']['database'] = \Drupal::service('file_system')->tempnam(sys_get_temp_dir(), 'sqlite'); 84 | 85 | // In order to change the Database::$databaseInfo array, need to remove 86 | // the active connection, then re-add it with the new info. 87 | -- 88 | GitLab 89 | 90 | 91 | From 68cd624acfe55284a7f6d1f2962d06a2d24ac9c1 Mon Sep 17 00:00:00 2001 92 | From: Matt Glaman 93 | Date: Wed, 24 Jul 2024 10:57:36 -0500 94 | Subject: [PATCH 3/3] StreamWrapperManager requires container argument 95 | 96 | --- 97 | core/includes/install.core.inc | 3 ++- 98 | 1 file changed, 2 insertions(+), 1 deletion(-) 99 | 100 | diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc 101 | index 2cfb092bb14f..b724d9e9bda9 100644 102 | --- a/core/includes/install.core.inc 103 | +++ b/core/includes/install.core.inc 104 | @@ -381,7 +381,8 @@ function install_begin_request($class_loader, &$install_state) { 105 | $container->set('class_loader', $class_loader); 106 | 107 | $container->set('settings', Settings::getInstance()); 108 | - $container->register('stream_wrapper_manager', StreamWrapperManager::class); 109 | + $container->register('stream_wrapper_manager', StreamWrapperManager::class) 110 | + ->addArgument($container); 111 | $container->register('file_system', FileSystem::class) 112 | ->addArgument(new Reference('stream_wrapper_manager')) 113 | ->addArgument(new Reference('settings')); 114 | -- 115 | GitLab 116 | -------------------------------------------------------------------------------- /patches/system-reqs-wasm.patch: -------------------------------------------------------------------------------- 1 | diff --git a/core/modules/system/system.install b/core/modules/system/system.install 2 | index b7deb9dd4b9..596793e3d4e 100644 3 | --- a/core/modules/system/system.install 4 | +++ b/core/modules/system/system.install 5 | @@ -396,7 +396,7 @@ function system_requirements($phase) { 6 | if (!OpCodeCache::isEnabled()) { 7 | $requirements['php_opcache'] = [ 8 | 'value' => t('Not enabled'), 9 | - 'severity' => REQUIREMENT_WARNING, 10 | + 'severity' => REQUIREMENT_OK, 11 | 'description' => t('PHP OPcode caching can improve your site\'s performance considerably. It is highly recommended to have OPcache installed on your server.'), 12 | ]; 13 | } 14 | @@ -1430,7 +1430,7 @@ function system_requirements($phase) { 15 | 'title' => t('Limited date range'), 16 | 'value' => t('Your PHP installation has a limited date range.'), 17 | 'description' => t('You are running on a system where PHP is compiled or limited to using 32-bit integers. This will limit the range of dates and timestamps to the years 1901-2038. Read about the limitations of 32-bit PHP.', [':url' => 'https://www.drupal.org/docs/system-requirements/limitations-of-32-bit-php']), 18 | - 'severity' => REQUIREMENT_WARNING, 19 | + 'severity' => REQUIREMENT_OK, 20 | ]; 21 | } 22 | 23 | -------------------------------------------------------------------------------- /postinstall.sh: -------------------------------------------------------------------------------- 1 | rsync --exclude='*[nN]ode*' --exclude='*[wW]ebview*' --exclude='php-tags*' --exclude='*index*' node_modules/php-*/*.mjs* public 2 | rsync --exclude='php8.0*' --exclude='php8.1*' --exclude='php8.2*' node_modules/*/*.so public 3 | -------------------------------------------------------------------------------- /public/assets/export.phpcode: -------------------------------------------------------------------------------- 1 | open('/persist/export.zip', ZipArchive::CREATE) !== TRUE) { 16 | print json_encode([ 17 | 'message' => 'export.zip could not be created', 18 | 'type' => 'error', 19 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 20 | exit(1); 21 | } 22 | 23 | $files = new RecursiveIteratorIterator( 24 | new RecursiveDirectoryIterator($docroot, FilesystemIterator::SKIP_DOTS) 25 | ); 26 | $total = iterator_count($files); 27 | $i = $percent = 0; 28 | foreach ($files as $name => $file) { 29 | if (is_dir($name)) { 30 | continue; 31 | } 32 | $added = $zip->addFile($name, str_replace($docroot, '', $name)); 33 | if ($added) { 34 | $newPercent = (++$i / $total); 35 | if ($newPercent - $percent >= 0.01) { 36 | print json_encode([ 37 | 'message' => 'Packing files ' . round($newPercent * 100, 2) . '%', 38 | 'type' => 'archive', 39 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 40 | $percent = $newPercent; 41 | } 42 | } else { 43 | print json_encode([ 44 | 'message' => 'Could not pack file ' . $name, 45 | 'type' => 'error', 46 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 47 | exit(); 48 | } 49 | } 50 | print json_encode([ 51 | 'message' => 'Packing files 100%', 52 | 'type' => 'archive', 53 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 54 | 55 | $zip->close(); 56 | 57 | print json_encode([ 58 | 'message' => 'Export archive created', 59 | 'type' => 'archive', 60 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 61 | 62 | exit(0); 63 | -------------------------------------------------------------------------------- /public/assets/init.phpcode: -------------------------------------------------------------------------------- 1 | 'artifact could not be found', 18 | 'type' => 'error', 19 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 20 | exit(1); 21 | } 22 | 23 | if($zip->open('/persist/artifact.zip', ZipArchive::RDONLY) === TRUE) 24 | { 25 | $total = $zip->count(); 26 | $percent = 0; 27 | for($i = 0; $i < $total; $i++) 28 | { 29 | $zip->extractTo($docroot, $zip->getNameIndex($i)); 30 | $newPercent = ((1+$i) / $total); 31 | 32 | if($newPercent - $percent >= 0.01) 33 | { 34 | print json_encode([ 35 | 'message' => 'Unpacking files ' . round($newPercent * 100, 2) . '%', 36 | 'type' => 'unarchive', 37 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 38 | $percent = $newPercent; 39 | } 40 | } 41 | print json_encode([ 42 | 'message' => 'Unpacking files 100%', 43 | 'type' => 'unarchive', 44 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 45 | } 46 | else { 47 | print json_encode([ 48 | 'message' => 'could not open artifact archive', 49 | 'type' => 'error', 50 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 51 | exit(1); 52 | } 53 | 54 | exit(0); 55 | -------------------------------------------------------------------------------- /public/assets/install-site.phpcode: -------------------------------------------------------------------------------- 1 | "Installed", 35 | 'type' => 'install', 36 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 37 | exit; 38 | } 39 | 40 | $class_loader = require $docroot . '/vendor/autoload.php'; 41 | 42 | $parameters = [ 43 | 'interactive' => FALSE, 44 | 'site_path' => 'sites/default', 45 | 'parameters' => [ 46 | 'profile' => $install_params['profile'] ?? 'standard', 47 | 'langcode' => $install_params['langcode'] ?? 'en', 48 | ], 49 | 'forms' => [ 50 | 'install_settings_form' => [ 51 | 'driver' => 'Drupal\sqlite\Driver\Database\sqlite', 52 | 'sqlite' => [ 53 | 'database' => 'sites/default/files/.sqlite', 54 | ], 55 | ], 56 | 'install_configure_form' => [ 57 | 'site_name' => $install_params['siteName'], 58 | 'site_mail' => 'drupal@localhost', 59 | 'account' => [ 60 | 'name' => 'admin', 61 | 'mail' => 'admin@localhost', 62 | 'pass' => [ 63 | 'pass1' => 'admin', 64 | 'pass2' => 'admin', 65 | ], 66 | ], 67 | 'enable_update_status_module' => TRUE, 68 | // \Drupal\Core\Render\Element\Checkboxes::valueCallback() requires 69 | // NULL instead of FALSE values for programmatic form submissions to 70 | // disable a checkbox. 71 | 'enable_update_status_emails' => NULL, 72 | ], 73 | ], 74 | ]; 75 | 76 | require_once 'core/includes/install.core.inc'; 77 | 78 | try { 79 | install_drupal($class_loader, $parameters, static function ($install_state) { 80 | static $started = FALSE; 81 | static $finished, $total = 0; 82 | if (!$started) { 83 | print json_encode([ 84 | 'message' => 'Beginning install tasks', 85 | 'type' => 'install', 86 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 87 | 88 | $started = TRUE; 89 | $total = count(install_tasks_to_perform($install_state)); 90 | } 91 | print json_encode([ 92 | 'message' => "Performing install task ($finished / $total)", 93 | 'type' => 'install', 94 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 95 | $finished++; 96 | }); 97 | 98 | chmod('sites/default', 0755); 99 | chmod('sites/default/settings.php', 0644); 100 | 101 | } catch (\Exception $e) { 102 | print json_encode([ 103 | 'message' => $e->getMessage(), 104 | 'type' => 'error', 105 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 106 | exit(1); 107 | } 108 | 109 | exit; 110 | -------------------------------------------------------------------------------- /public/assets/login-admin.phpcode: -------------------------------------------------------------------------------- 1 | $install_params['host'] ?? 'localhost', 27 | 'REMOTE_ADDR' => '127.0.0.1', 28 | 'SCRIPT_FILENAME' => "/persist/$flavor/web/index.php", 29 | 'SCRIPT_NAME' => "/cgi/$flavor/index.php" 30 | ]); 31 | $kernel = DrupalKernel::createFromRequest($request, $class_loader, 'prod'); 32 | $kernel->boot(); 33 | $kernel->loadLegacyIncludes(); 34 | 35 | $container = $kernel->getContainer(); 36 | $container->get('request_stack')->push($request); 37 | 38 | $container->get('module_handler')->loadAll(); 39 | 40 | /** @var \Symfony\Component\HttpFoundation\Session\Session $session */ 41 | $session = $container->get('session'); 42 | $session->start(); 43 | $request->setSession($session); 44 | 45 | $account = User::load(1); 46 | user_login_finalize($account); 47 | 48 | try { 49 | $session->save(); 50 | } catch (\Throwable $e) { 51 | print json_encode([ 52 | 'message' => 'could not get login session', 53 | 'trace' => $e->getTraceAsString(), 54 | 'type' => 'login', 55 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 56 | exit(1); 57 | } 58 | 59 | print json_encode([ 60 | 'message' => 'Logged in!', 61 | 'params' => [ 62 | 'name' => $session->getName(), 63 | 'id' => $session->getId(), 64 | ], 65 | 'type' => 'set_cookie', 66 | ], JSON_THROW_ON_ERROR) . PHP_EOL; 67 | -------------------------------------------------------------------------------- /public/cms.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Try Drupal CMS 7 | 8 | 9 | 10 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/cookie-map.mjs: -------------------------------------------------------------------------------- 1 | export default class CookieMap extends Map { 2 | 3 | name = '/cookies' 4 | store = 'COOKIES' 5 | 6 | constructor(iterable) { 7 | super(); 8 | 9 | this._db = this._openDB(); 10 | this._db.then(db => { 11 | const transaction = db.transaction(this.store, 'readonly'); 12 | const store = transaction.objectStore(this.store); 13 | 14 | const request = store.getAll(); 15 | request.onsuccess = (event) => { 16 | const entries = event.target.result; 17 | entries.forEach(({ key, value }) => super.set(key, value)); 18 | iterable.forEach(([ key, value ]) => { 19 | this.set(key, value) 20 | }); 21 | }; 22 | }); 23 | } 24 | 25 | _openDB() { 26 | return new Promise((resolve, reject) => { 27 | const request = indexedDB.open(this.name); 28 | 29 | request.onupgradeneeded = (event) => { 30 | const db = event.target.result; 31 | if (!db.objectStoreNames.contains(this.store)) { 32 | db.createObjectStore(this.store, { keyPath: 'key' }); 33 | } 34 | }; 35 | 36 | request.onsuccess = (event) => resolve(event.target.result); 37 | request.onerror = (event) => reject(event.target.error); 38 | }); 39 | } 40 | 41 | get(key) { 42 | return super.get(key); 43 | } 44 | 45 | set(key, value) { 46 | if (value === 'deleted') { 47 | return this.delete(key) 48 | } 49 | super.set(key, value) 50 | this._persist(key, value); 51 | return this; 52 | } 53 | 54 | delete(key) { 55 | const result = super.delete(key); 56 | this._delete(key); 57 | return result; 58 | } 59 | 60 | clear() { 61 | super.clear(); 62 | this._clear(); 63 | } 64 | 65 | async _persist(key, value) { 66 | const store = await this._getTransaction() 67 | store.put({value, key}); 68 | } 69 | 70 | async _delete(key) { 71 | const store = await this._getTransaction() 72 | store.delete(key); 73 | } 74 | 75 | async _clear() { 76 | const store = await this._getTransaction() 77 | store.clear(); 78 | } 79 | 80 | async _getTransaction() { 81 | const db = await this._db; 82 | const transaction = db.transaction(this.store, 'readwrite'); 83 | return transaction.objectStore(this.store); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/drupal-cgi-worker.mjs: -------------------------------------------------------------------------------- 1 | import {getBroadcastChannel} from "./utils.mjs"; 2 | import CookieMap from "./cookie-map.mjs"; 3 | 4 | // Fibers are not yet supported in the Wasm runtime. 5 | // Instead of uninstalling BigPipe, set the `big_pipe_nojs` cookie which disables its functionality. 6 | // This will make it easier to export the trial experience in the future. 7 | const cookies = new CookieMap([ 8 | ['big_pipe_nojs', '1'] 9 | ]); 10 | 11 | const onRequest = (request, response) => { 12 | const url = new URL(request.url); 13 | const logLine = 14 | `[${new Date().toISOString()}]` + 15 | ` 127.0.0.1 - "${request.method}` + 16 | ` ${url.pathname}" - HTTP/1.1 ${response.status}`; 17 | console.log(logLine); 18 | }; 19 | const notFound = (request) => { 20 | console.log(request) 21 | return new Response(`

404

${request.url} not found`, { 22 | status: 404, 23 | headers: {"Content-Type": "text/html"}, 24 | }); 25 | }; 26 | 27 | const sharedLibs = [ 28 | `php\${PHP_VERSION}-zlib.so`, 29 | `php\${PHP_VERSION}-zip.so`, 30 | `php\${PHP_VERSION}-iconv.so`, 31 | `php\${PHP_VERSION}-gd.so`, 32 | `php\${PHP_VERSION}-dom.so`, 33 | `php\${PHP_VERSION}-mbstring.so`, 34 | `php\${PHP_VERSION}-sqlite.so`, 35 | `php\${PHP_VERSION}-pdo-sqlite.so`, 36 | `php\${PHP_VERSION}-xml.so`, 37 | `php\${PHP_VERSION}-simplexml.so`, 38 | ]; 39 | 40 | export function setUpWorker(worker, PhpCgiWorker, prefix, docroot) { 41 | const php = new PhpCgiWorker({ 42 | onRequest, 43 | notFound, 44 | sharedLibs, 45 | prefix, 46 | docroot, 47 | types: { 48 | jpeg: "image/jpeg", 49 | jpg: "image/jpeg", 50 | gif: "image/gif", 51 | png: "image/png", 52 | svg: "image/svg+xml" 53 | }, 54 | env: { 55 | HTTP_USER_AGENT: worker.navigator.userAgent, 56 | }, 57 | ini: ` 58 | date.timezone=${Intl.DateTimeFormat().resolvedOptions().timeZone} 59 | `, 60 | cookies, 61 | }) 62 | 63 | const channel = getBroadcastChannel() 64 | channel.addEventListener('message', async ({ data }) => { 65 | const { action, params } = data; 66 | if (action === 'refresh') { 67 | await navigator.locks.request('cgi-worker-action', async () => { 68 | console.log('Refreshing CGI') 69 | php.refresh(); 70 | }); 71 | } 72 | if (action === 'set_vhost') { 73 | await navigator.locks.request('cgi-worker-action', async () => { 74 | const vHost = { 75 | pathPrefix: `/cgi/${params.flavor}`, 76 | directory: `/persist/${params.flavor}/web`, 77 | entrypoint: 'index.php', 78 | }; 79 | const settings = await php.getSettings(); 80 | const vHostExists = settings.vHosts.find(existing => existing.pathPrefix === vHost.pathPrefix); 81 | 82 | if (!vHostExists) { 83 | settings.vHosts.push(vHost) 84 | await php.setSettings(settings) 85 | await php.storeInit() 86 | } 87 | }); 88 | } 89 | else if (action === 'set_cookie') { 90 | await navigator.locks.request('cgi-worker-action', async () => { 91 | cookies.set(params.name, params.id) 92 | }); 93 | } 94 | }) 95 | 96 | worker.addEventListener('install', event => php.handleInstallEvent(event)); 97 | worker.addEventListener('activate', event => php.handleActivateEvent(event)); 98 | worker.addEventListener('activate', () => { 99 | channel.postMessage({ 100 | action: 'service_worker_activated' 101 | }) 102 | }); 103 | worker.addEventListener('fetch', event => php.handleFetchEvent(event)); 104 | worker.addEventListener('message', event => php.handleMessageEvent(event)); 105 | 106 | return php 107 | } 108 | -------------------------------------------------------------------------------- /public/drupal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Try Drupal Core 7 | 8 | 9 | 10 |
11 |
12 |

13 | Drupal 14 | Core 15 |

16 |
17 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mglaman/wasm-drupal/c3edec34faa50e3cbb849ba1b81fdd74252d801c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Try Drupal 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | 21 |
22 |
23 |

Browser compatibility warnings

24 |
25 |
    26 |
  • Safari: Errors on Safari with php-cgi-worker and WASM #28 28 |
  • 29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | 40 | 45 | 46 | 55 | 69 | 70 | 71 | 72 |
73 | 79 |
80 | 93 |
94 | 103 |
104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /public/install-worker.mjs: -------------------------------------------------------------------------------- 1 | import { PhpWorker } from './PhpWorker.mjs' 2 | import { getBroadcastChannel } from "./utils.mjs"; 3 | 4 | const sharedLibs = [ 5 | `php${PhpWorker.phpVersion}-zip.so`, 6 | `php${PhpWorker.phpVersion}-zlib.so`, 7 | `php${PhpWorker.phpVersion}-iconv.so`, 8 | `php${PhpWorker.phpVersion}-gd.so`, 9 | `php${PhpWorker.phpVersion}-dom.so`, 10 | `php${PhpWorker.phpVersion}-mbstring.so`, 11 | `php${PhpWorker.phpVersion}-sqlite.so`, 12 | `php${PhpWorker.phpVersion}-pdo-sqlite.so`, 13 | `php${PhpWorker.phpVersion}-xml.so`, 14 | `php${PhpWorker.phpVersion}-simplexml.so`, 15 | ]; 16 | 17 | console.log('booting PhpWorker') 18 | const php = new PhpWorker({ 19 | sharedLibs, 20 | persist: [{ mountPath: '/persist' }, { mountPath: '/config' }], 21 | ini: ` 22 | date.timezone=${Intl.DateTimeFormat().resolvedOptions().timeZone} 23 | ` 24 | }) 25 | php.addEventListener('output', event => { 26 | event.detail.forEach(detail => { 27 | try { 28 | const data = JSON.parse(detail.trim()); 29 | postMessage({ 30 | action: `status`, 31 | ...data 32 | }) 33 | } catch (e) { 34 | console.log(detail) 35 | } 36 | }) 37 | }); 38 | php.addEventListener('error', event => console.log(event.detail)); 39 | 40 | self.onmessage = async ({data }) => { 41 | const { action, params } = data; 42 | 43 | if (action === 'start') { 44 | await navigator.locks.request('start', async () => { 45 | console.log('Starting') 46 | postMessage({ 47 | action: `started`, 48 | params, 49 | message: 'Starting' 50 | }) 51 | 52 | const { flavor, artifact } = params; 53 | 54 | getBroadcastChannel().postMessage({ 55 | action: 'set_vhost', 56 | params 57 | }) 58 | 59 | const checkWww = await php.analyzePath(`/persist/${flavor}`) 60 | if (checkWww.exists) { 61 | postMessage({ 62 | action: `finished`, 63 | params, 64 | message: 'Site already exists' 65 | }) 66 | } else { 67 | const checkArchive = await php.analyzePath('/persist/artifact.zip'); 68 | if (checkArchive.exists) { 69 | postMessage({ 70 | action: 'status', 71 | params, 72 | message: 'Removing existing archive' 73 | }) 74 | console.log('Removing archive'); 75 | await php.unlink('/persist/artifact.zip') 76 | } 77 | postMessage({ 78 | action: 'status', 79 | params, 80 | message: 'Downloading archive' 81 | }) 82 | const downloader = fetch(artifact); 83 | 84 | const download = await downloader.then(response => { 85 | const contentEncoding = response.headers.get('content-encoding'); 86 | const contentLength = response.headers.get(contentEncoding ? 'x-file-size' : 'content-length'); 87 | const total = parseInt(contentLength, 10); 88 | let loaded = 0; 89 | 90 | return new Response( 91 | new ReadableStream({ 92 | start(controller) { 93 | if (typeof response.body.getReader !== 'function') { 94 | // @todo only here to make test pass, mock in test. 95 | return; 96 | } 97 | const reader = response.body.getReader(); 98 | read(); 99 | function read() { 100 | reader.read().then(({done, value}) => { 101 | if (done) { 102 | controller.close(); 103 | return; 104 | } 105 | loaded += value.byteLength; 106 | postMessage({ 107 | action: 'status', 108 | params, 109 | message: `Downloading archive ${Math.round(loaded/total*100)+'%'}` 110 | }) 111 | controller.enqueue(value); 112 | read(); 113 | }).catch(error => { 114 | console.error(error); 115 | controller.error(error) 116 | }) 117 | } 118 | } 119 | }) 120 | ); 121 | }) 122 | 123 | postMessage({ 124 | action: 'status', 125 | params, 126 | message: 'Saving archive' 127 | }) 128 | console.log('Converting archive to array buffer'); 129 | const zipContents = await download.arrayBuffer(); 130 | await php.writeFile('/config/flavor.txt', flavor) 131 | console.log('Writing archive to disk'); 132 | await php.writeFile('/persist/artifact.zip', new Uint8Array(zipContents)) 133 | 134 | postMessage({ 135 | action: 'status', 136 | params, 137 | message: 'Extracting archive' 138 | }) 139 | const initPhpCode = fetch('/assets/init.phpcode'); 140 | await php.binary; 141 | 142 | const initPhpExitCode = await php.run(await (await initPhpCode).text()); 143 | console.log(initPhpExitCode) 144 | 145 | const installType = params.installParameters.installType; 146 | if (installType !== 'interactive') { 147 | postMessage({ 148 | action: 'status', 149 | params, 150 | message: 'Preparing site', 151 | }); 152 | 153 | console.log('Writing install parameters'); 154 | await php.writeFile(`/config/${flavor}-install-params.json`, JSON.stringify({ 155 | langcode: 'en', 156 | host: (new URL(globalThis.location || 'http://localhost')).host, 157 | ...params.installParameters, 158 | })); 159 | 160 | if (installType === 'automated') { 161 | console.log('Installing site'); 162 | 163 | await php.run(` { 215 | const openDb = indexedDB.open("/persist", 21); 216 | openDb.onsuccess = () => { 217 | const db = openDb.result; 218 | const transaction = db.transaction(["FILE_DATA"], "readwrite"); 219 | const objectStore = transaction.objectStore("FILE_DATA"); 220 | // IDBKeyRange.bound trick found at https://stackoverflow.com/a/76714057/1949744 221 | const objectStoreRequest = objectStore.delete(IDBKeyRange.bound(`/persist/${flavor}`, `/persist/${flavor}/\uffff`)); 222 | 223 | objectStoreRequest.onsuccess = () => { 224 | db.close(); 225 | postMessage({ 226 | action: 'reload' 227 | }) 228 | }; 229 | }; 230 | }) 231 | } 232 | else if (action === 'stop') { 233 | self.close() 234 | } 235 | else if (action === 'export') { 236 | const { flavor } = params; 237 | await self.navigator.locks.request('export', async () => { 238 | postMessage({ 239 | action: `started`, 240 | params, 241 | message: 'Preparing to export' 242 | }) 243 | await php.writeFile('/config/flavor.txt', flavor) 244 | 245 | console.log('fetching export code') 246 | const exportPhpCode = fetch('/assets/export.phpcode'); 247 | await php.binary; 248 | 249 | console.log('running export code') 250 | const exportPhpExitCode = await php.run(await (await exportPhpCode).text()) 251 | console.log(exportPhpExitCode) 252 | 253 | await php.unlink('/config/flavor.txt') 254 | postMessage({ 255 | action: `status`, 256 | params, 257 | message: 'Preparing download' 258 | }) 259 | 260 | const exportContents = await php.readFile('/persist/export.zip') 261 | const blob = new Blob([exportContents], { type: 'application/zip' }) 262 | 263 | postMessage({ 264 | action: `export_finished`, 265 | params: { 266 | ...params, 267 | export: blob 268 | }, 269 | message: 'Download ready' 270 | }) 271 | setTimeout(() => php.unlink('/persist/export.zip'), 0) 272 | 273 | }) 274 | } 275 | else if (action === 'check_existing') { 276 | console.log('Checking for existing session') 277 | const { flavor } = params; 278 | const check = await php.analyzePath(`/persist/${flavor}`) 279 | postMessage({ 280 | action: `check_existing_finished`, 281 | params: { 282 | exists: check.exists, 283 | ...params 284 | } 285 | }) 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /public/main.mjs: -------------------------------------------------------------------------------- 1 | import { registerWorker } from './utils.mjs' 2 | import { defineTrialManagerElement } from "./trial-manager.mjs"; 3 | 4 | defineTrialManagerElement() 5 | registerWorker( 6 | `${window.location.origin}/service-worker.mjs`, 7 | `${window.location.origin}/service-worker.js` 8 | ) 9 | -------------------------------------------------------------------------------- /public/noto-sans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mglaman/wasm-drupal/c3edec34faa50e3cbb849ba1b81fdd74252d801c/public/noto-sans.woff2 -------------------------------------------------------------------------------- /public/service-worker.mjs: -------------------------------------------------------------------------------- 1 | import { PhpCgiWorker } from "./PhpCgiWorker.mjs"; 2 | import {setUpWorker} from "./drupal-cgi-worker.mjs"; 3 | 4 | setUpWorker(self, PhpCgiWorker, '/cgi/', '/persist/www') 5 | -------------------------------------------------------------------------------- /public/trial-manager.mjs: -------------------------------------------------------------------------------- 1 | import {getBroadcastChannel} from "./utils.mjs"; 2 | 3 | export default class TrialManager extends HTMLElement { 4 | static observedAttributes = ['mode', 'message']; 5 | constructor() { 6 | super() 7 | this.worker = new Worker('/install-worker.mjs', { 8 | type: "module" 9 | }); 10 | this.worker.onmessage = this.onMessage.bind(this) 11 | 12 | this.channel = getBroadcastChannel() 13 | this.channel.addEventListener('message', ({ data }) => { 14 | const { action } = data; 15 | if (action === 'service_worker_ready') { 16 | this.sendWorkerAction('check_existing', { 17 | flavor: this.flavor 18 | }) 19 | } 20 | }) 21 | 22 | this.spinner = this.initializingEl() 23 | this.actions = this.actionsEl() 24 | this.progress = this.progressEl() 25 | } 26 | 27 | render() { 28 | const mode = this.mode; 29 | if (!mode) { 30 | this.replaceChildren(this.spinner) 31 | } else if (mode === 'new_session') { 32 | this.replaceChildren(this.progress) 33 | } else if (mode === 'existing_session') { 34 | this.replaceChildren(this.actions) 35 | } 36 | } 37 | 38 | get mode() { 39 | return this.getAttribute('mode') 40 | } 41 | 42 | set mode(mode) { 43 | this.setAttribute('mode', mode) 44 | } 45 | 46 | get flavor() { 47 | return this.getAttribute('flavor') || 'drupal' 48 | } 49 | 50 | set flavor(flavor) { 51 | this.setAttribute('flavor', flavor) 52 | } 53 | 54 | get artifact() { 55 | // Allow manually overriding the artifact used. 56 | if (this.hasAttribute('artifact')) { 57 | return this.getAttribute('artifact') 58 | } 59 | 60 | const artifactName = this._isMobile() ? 'trial-installed.zip' : 'trial.zip'; 61 | return `https://git.drupalcode.org/api/v4/projects/157093/jobs/artifacts/0.x/raw/${artifactName}?job=build+trial+artifact` 62 | } 63 | 64 | set artifact(artifact) { 65 | this.setAttribute('artifact', artifact) 66 | } 67 | 68 | get message() { 69 | return this.getAttribute('message') || ''; 70 | } 71 | 72 | set message(message) { 73 | this.setAttribute('message', message) 74 | } 75 | 76 | get installType() { 77 | if (!this.hasAttribute('install-type')) { 78 | return !this._isMobile() ? 'interactive' : 'preinstalled' 79 | } 80 | return this.getAttribute('install-type') 81 | } 82 | 83 | get siteName() { 84 | if (!this.hasAttribute('site-name')) { 85 | return 'Try' 86 | } 87 | return this.getAttribute('site-name') 88 | } 89 | 90 | connectedCallback() { 91 | this.render() 92 | } 93 | 94 | disconnectedCallback() { 95 | this.worker.terminate(); 96 | } 97 | 98 | sendWorkerAction(action, params) { 99 | this.worker.postMessage({action, params}) 100 | } 101 | 102 | onMessage({ data }) { 103 | const { action, message, type } = data; 104 | 105 | if (type === 'error') { 106 | this.worker.postMessage({ action: 'stop' }) 107 | } 108 | 109 | if (type === 'set_cookie') { 110 | this.channel.postMessage({ 111 | action: 'set_cookie', 112 | params: data.params 113 | }) 114 | } 115 | 116 | if (action === 'started') { 117 | this.setAttribute('mode', 'new_session'); 118 | this.setAttribute('message', 'Starting') 119 | } 120 | else if (action === 'status') { 121 | this.setAttribute('message', message) 122 | } 123 | else if (action === 'finished') { 124 | this.setAttribute('message', 'Refreshing data') 125 | this.channel.postMessage({ 126 | action: 'refresh' 127 | }) 128 | 129 | this.setAttribute('message', 'Redirecting to your site') 130 | window.location = `/cgi/${this.flavor}` 131 | } 132 | else if (action === 'reload') { 133 | this.channel.postMessage({ 134 | action: 'refresh' 135 | }) 136 | window.location.reload(); 137 | } 138 | else if (action === 'export_finished') { 139 | this.setAttribute('message', message) 140 | const link = document.createElement('a'); 141 | link.href = URL.createObjectURL(data.params.export); 142 | link.download = 'drupal.zip' 143 | link.click(); 144 | URL.revokeObjectURL(link.href); 145 | this.setAttribute('mode', 'existing_session'); 146 | } 147 | else if (action === 'check_existing_finished') { 148 | if (data.params.exists) { 149 | this.setAttribute('mode', 'existing_session'); 150 | } else { 151 | this.worker.postMessage({ 152 | action: 'start', 153 | params: { 154 | flavor: this.flavor, 155 | artifact: this.artifact, 156 | installParameters: { 157 | // @see install-site.phpcode 158 | installType: this.installType, 159 | siteName: this.siteName, 160 | langcode: 'en', 161 | } 162 | } 163 | }) 164 | } 165 | } 166 | else { 167 | console.log(data) 168 | } 169 | } 170 | 171 | attributeChangedCallback(name, oldValue, newValue) { 172 | if (name === 'mode') { 173 | this.render() 174 | this.setAttribute('message', '') 175 | } 176 | else if (name === 'message') { 177 | this.progress.innerText = newValue 178 | } 179 | } 180 | 181 | initializingEl() { 182 | const el = document.createElement('div'); 183 | el.classList.add('flex', 'justify-center') 184 | el.innerHTML = ` 185 | 186 | 187 | ` 188 | return el; 189 | } 190 | actionsEl() { 191 | const buttonWrapper = document.createElement('div'); 192 | buttonWrapper.classList.add('isolate', 'inline-flex', 'space-x-2'); 193 | 194 | const buttonClasses = [ 195 | 'bg-white', 196 | 'relative', 197 | 'inline-flex', 198 | 'items-center', 199 | 'px-4', 200 | 'py-1', 201 | 'font-semibold', 202 | 'text-drupal-darkBlue', 203 | 'border-2', 204 | 'border-drupal-darkBlue', 205 | 'rounded-md', 206 | 'hover:border-drupal-blue', 207 | 'hover:text-drupal-blue' 208 | ]; 209 | const resumeButton = document.createElement('button'); 210 | resumeButton.classList.add(...buttonClasses) 211 | resumeButton.id = 'resume'; 212 | resumeButton.innerText = 'Resume' 213 | resumeButton.addEventListener('click', () => { 214 | this.sendWorkerAction('start', { 215 | flavor: this.flavor, 216 | artifact: this.artifact 217 | }) 218 | }) 219 | 220 | const newButton = document.createElement('button'); 221 | newButton.classList.add(...buttonClasses) 222 | newButton.id = 'new' 223 | newButton.innerText = 'New' 224 | newButton.addEventListener('click', () => { 225 | if (window.confirm("Your site's data will be completely removed, do you want to continue?")) { 226 | this.removeAttribute('mode'); 227 | this.sendWorkerAction('remove', { 228 | flavor: this.flavor 229 | }) 230 | } 231 | }) 232 | 233 | const exportButton = document.createElement('button') 234 | exportButton.classList.add(...buttonClasses, 'group') 235 | exportButton.id = 'export' 236 | exportButton.innerHTML = ` 237 | 238 | Download 239 | ` 240 | exportButton.addEventListener('click', () => { 241 | this.removeAttribute('mode'); 242 | this.sendWorkerAction('export', { 243 | flavor: this.flavor 244 | }) 245 | }) 246 | 247 | buttonWrapper.appendChild(resumeButton) 248 | buttonWrapper.appendChild(newButton) 249 | buttonWrapper.appendChild(exportButton) 250 | 251 | const container = document.createElement('div'); 252 | container.classList.add('text-center') 253 | const message = document.createElement('p'); 254 | message.classList.add('mb-4', 'text-lg') 255 | message.innerText = 'You already have a site, what would you like to do?' 256 | container.appendChild(message) 257 | container.appendChild(buttonWrapper) 258 | 259 | return container 260 | } 261 | 262 | progressEl() { 263 | const progress = document.createElement('div') 264 | progress.classList.add('rounded-md', 'p-4', 'bg-white', 'w-96', 'text-center', 'w-full') 265 | progress.innerText = this.message 266 | return progress 267 | } 268 | 269 | _isMobile() { 270 | return (navigator.maxTouchPoints || 'ontouchstart' in document.documentElement) 271 | } 272 | } 273 | 274 | export function defineTrialManagerElement() { 275 | customElements.get('trial-manager') || customElements.define('trial-manager', TrialManager) 276 | } 277 | -------------------------------------------------------------------------------- /public/utils.mjs: -------------------------------------------------------------------------------- 1 | export function getBroadcastChannel() { 2 | return new BroadcastChannel('drupal-cgi-worker'); 3 | } 4 | 5 | export function registerWorker(moduleUrl, bundledUrl) { 6 | function registrationReady() { 7 | getBroadcastChannel().postMessage({ 8 | action: 'service_worker_ready', 9 | }) 10 | } 11 | const serviceWorker = navigator.serviceWorker; 12 | serviceWorker.register(moduleUrl, { 13 | type: "module" 14 | }) 15 | .then(registrationReady) 16 | .catch(() => { 17 | console.log('Browser did not support ES modules in service worker, trying bundled service worker') 18 | serviceWorker.register(bundledUrl) 19 | .then(registrationReady) 20 | .catch(error => { 21 | alert("There was an error loading the service worker. Check known compatibility issues and your browser's developer console.") 22 | console.error(error) 23 | }); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Noto Sans'; 3 | font-style: normal; 4 | font-weight: 100 900; 5 | font-stretch: 100%; 6 | font-display: swap; 7 | src: url(noto-sans.woff2) format('woff2'); 8 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 9 | } 10 | 11 | @tailwind base; 12 | @tailwind components; 13 | @tailwind utilities; 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | 4 | // Colors from https://drupal.widencollective.com/portals/gfvztttq/BrandPortal 5 | /** @type {import('tailwindcss').Config} */ 6 | module.exports = { 7 | content: ["./public/**/*.{html,js,mjs}"], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | 'sans': ['"Noto Sans"', ...defaultTheme.fontFamily.sans], 12 | }, 13 | colors: { 14 | drupal: { 15 | blue: "#009CDE", 16 | darkBlue: "#006AA9", 17 | navy: "#12285F", 18 | lightBlue: "#CCEDF9", 19 | purple: "#CCBAF4", 20 | yellow: "#FFC423", 21 | rec: "#F46351", 22 | green: "#397618", 23 | white: "#FFFFFF", 24 | black: "#000000" 25 | } 26 | } 27 | }, 28 | }, 29 | plugins: [ 30 | require('@tailwindcss/typography'), 31 | ], 32 | } 33 | 34 | -------------------------------------------------------------------------------- /tests/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /tests/init-phpcode.test.js: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, afterEach, beforeEach} from 'vitest' 2 | import fs from "node:fs"; 3 | import { 4 | createPhp, 5 | runPhpCode, 6 | assertOutput, 7 | rootPath, setupFixturePaths, cleanupFixturePaths, writeFlavorTxt, copyArtifactFixture 8 | } from './utils' 9 | 10 | describe('init.phpcode', () => { 11 | beforeEach(setupFixturePaths) 12 | afterEach(cleanupFixturePaths) 13 | 14 | it('errors if artifact not found', async ({ configFixturePath, persistFixturePath }) => { 15 | writeFlavorTxt(configFixturePath) 16 | 17 | const [stdOut, stdErr, php] = createPhp({ configFixturePath, persistFixturePath }) 18 | await runPhpCode(php, rootPath + '/public/assets/init.phpcode') 19 | 20 | assertOutput(stdOut, '{"message":"artifact could not be found","type":"error"}') 21 | assertOutput(stdErr, '') 22 | }) 23 | it('errors if artifact cannot be opened properly', async ({ configFixturePath, persistFixturePath }) => { 24 | writeFlavorTxt(configFixturePath) 25 | fs.writeFileSync(`${persistFixturePath}/artifact.zip`, 'definitely not a zip file') 26 | 27 | const [stdOut, stdErr, php] = createPhp({ configFixturePath, persistFixturePath }) 28 | await runPhpCode(php, rootPath + '/public/assets/init.phpcode') 29 | 30 | assertOutput(stdOut, '{"message":"could not open artifact archive","type":"error"}') 31 | assertOutput(stdErr, '') 32 | }) 33 | it('extracts an archive correctly', async ({ configFixturePath, persistFixturePath }) => { 34 | writeFlavorTxt(configFixturePath) 35 | copyArtifactFixture(persistFixturePath, 'drupal-core.zip') 36 | 37 | const [stdOut, stdErr, php] = createPhp({ configFixturePath, persistFixturePath }) 38 | await runPhpCode(php, rootPath + '/public/assets/init.phpcode') 39 | 40 | expect(stdOut.pop().trim()).toStrictEqual('{"message":"Unpacking files 100%","type":"unarchive"}') 41 | assertOutput(stdErr, '') 42 | 43 | expect(fs.existsSync(`${persistFixturePath}/drupal`)).toBe(true) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /tests/install-site-phpcode.test.js: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, afterEach, beforeEach} from 'vitest' 2 | import fs from "node:fs"; 3 | import { 4 | createPhp, 5 | runPhpCode, 6 | assertOutput, 7 | rootPath, 8 | rootFixturePath, 9 | setupFixturePaths, 10 | cleanupFixturePaths, 11 | writeFlavorTxt, 12 | copyArtifactFixture, 13 | createCgiPhp, 14 | copyExistingBuildFixture, writeInstallParams, doRequest, checkForMetaRefresh, 15 | assertSitesDefaultDirectoryPermissions 16 | } from './utils' 17 | 18 | 19 | describe('install-site.phpcode', () => { 20 | beforeEach(setupFixturePaths) 21 | afterEach(cleanupFixturePaths) 22 | 23 | it('installs the site from artifact', async ({ configFixturePath, persistFixturePath }) => { 24 | writeFlavorTxt(configFixturePath, 'drupal') 25 | writeInstallParams(configFixturePath, { 26 | langcode: 'en', 27 | skip: false, 28 | siteName: 'test', 29 | profile: 'standard', 30 | recipes: [], 31 | autoLogin: true, 32 | host: globalThis.location.host, 33 | installType: 'automated', 34 | }) 35 | copyArtifactFixture(persistFixturePath, 'drupal-core.zip') 36 | 37 | const [stdOut, stdErr, php] = createPhp({ configFixturePath, persistFixturePath }) 38 | 39 | await runPhpCode(php, rootPath + '/public/assets/init.phpcode') 40 | 41 | expect(stdOut.pop().trim()).toStrictEqual('{"message":"Unpacking files 100%","type":"unarchive"}') 42 | assertOutput(stdErr, '') 43 | 44 | expect(fs.existsSync(`${persistFixturePath}/drupal`)).toBe(true) 45 | stdOut.length = 0; 46 | 47 | await runPhpCode(php, rootPath + '/public/assets/install-site.phpcode') 48 | 49 | expect(stdOut.shift().trim()).toStrictEqual('{"message":"Beginning install tasks","type":"install"}') 50 | expect(stdOut.pop().trim()).toStrictEqual('{"message":"Performing install task (12 \\/ 12)","type":"install"}') 51 | assertOutput(stdErr, '') 52 | stdOut.length = 0; 53 | 54 | await runPhpCode(php, rootPath + '/public/assets/login-admin.phpcode') 55 | const loginOutput = JSON.parse(stdOut.join('').trim()); 56 | expect(loginOutput).toHaveProperty('type') 57 | expect(loginOutput.type).toStrictEqual('set_cookie') 58 | expect(loginOutput).toHaveProperty('params') 59 | expect(loginOutput.params).toHaveProperty('name') 60 | expect(loginOutput.params).toHaveProperty('id') 61 | assertOutput(stdErr, '') 62 | stdOut.length = 0; 63 | 64 | assertSitesDefaultDirectoryPermissions(persistFixturePath) 65 | 66 | const [cgiOut, cgiErr, phpCgi] = createCgiPhp({ configFixturePath, persistFixturePath }); 67 | phpCgi.cookies.set(loginOutput.params.name, loginOutput.params.id) 68 | 69 | const response = await phpCgi.request({ 70 | connection: { 71 | encrypted: false, 72 | }, 73 | method: 'GET', 74 | url: '/cgi/drupal', 75 | headers: { 76 | host: globalThis.location.host 77 | } 78 | }) 79 | assertOutput(cgiOut, 'GET /cgi/drupal 200') 80 | assertOutput(cgiErr, '') 81 | 82 | expect(response.headers.get('x-generator')).toMatch(/Drupal \d+ \(https:\/\/www\.drupal\.org\)/) 83 | const text = await response.text() 84 | 85 | // Assert custom site title. 86 | expect(text).toContain('Welcome! | test') 87 | // Verify CSS/JS aggregation turned off 88 | expect(text).toContain('cgi/drupal/core/themes/olivero/css') 89 | expect(text).toContain('cgi/drupal/core/themes/olivero/js') 90 | 91 | expect(text).toContain('

Congratulations and welcome to the Drupal community.

') 92 | 93 | expect(text).toContain('/cgi/drupal/user/logout') 94 | }) 95 | it('installs from existing source', async ({ configFixturePath, persistFixturePath }) => { 96 | writeFlavorTxt(configFixturePath) 97 | writeInstallParams(configFixturePath, { 98 | langcode: 'en', 99 | skip: false, 100 | siteName: 'test', 101 | profile: 'standard', 102 | recipes: [], 103 | host: globalThis.location.host, 104 | installType: 'automated', 105 | }) 106 | copyExistingBuildFixture(persistFixturePath, 'drupal-core') 107 | 108 | const [stdOut, stdErr, php] = createPhp({ configFixturePath, persistFixturePath }) 109 | await runPhpCode(php, rootPath + '/public/assets/install-site.phpcode') 110 | expect(stdOut.shift().trim()).toStrictEqual('{"message":"Beginning install tasks","type":"install"}') 111 | expect(stdOut.pop().trim()).toStrictEqual('{"message":"Performing install task (12 \\/ 12)","type":"install"}') 112 | assertOutput(stdErr, '') 113 | stdOut.length = 0 114 | 115 | await runPhpCode(php, rootPath + '/public/assets/login-admin.phpcode') 116 | const loginOutput = JSON.parse(stdOut.join('').trim()); 117 | 118 | assertSitesDefaultDirectoryPermissions(persistFixturePath) 119 | 120 | const [cgiOut, cgiErr, phpCgi] = createCgiPhp({ configFixturePath, persistFixturePath }); 121 | phpCgi.cookies.set(loginOutput.params.name, loginOutput.params.id) 122 | 123 | const response = await phpCgi.request({ 124 | connection: { 125 | encrypted: false, 126 | }, 127 | method: 'GET', 128 | url: '/cgi/drupal', 129 | headers: { 130 | host: globalThis.location.host 131 | } 132 | }) 133 | const text = await response.text() 134 | assertOutput(cgiOut, 'GET /cgi/drupal 200') 135 | assertOutput(cgiErr, '') 136 | expect(text).toContain('/cgi/drupal/user/logout') 137 | }) 138 | // @todo skip, need to see why "The PGlite class must be provided as a constructor arg to PHP to use PGlite." is thrown 139 | // probably because Drupal sees pgsql as an option but it isn't, really. 140 | it.skip('works with interactive installer', async ({ configFixturePath, persistFixturePath }) => { 141 | writeFlavorTxt(configFixturePath) 142 | writeInstallParams(configFixturePath, { 143 | langcode: 'en', 144 | skip: false, 145 | siteName: 'test', 146 | profile: 'standard', 147 | recipes: [], 148 | host: globalThis.location.host, 149 | installType: 'interactive', 150 | }) 151 | copyExistingBuildFixture(persistFixturePath, 'drupal-core') 152 | 153 | const [cgiOut, cgiErr, phpCgi] = createCgiPhp({ configFixturePath, persistFixturePath }); 154 | 155 | const [initResponse, initText] = await doRequest(phpCgi, '/cgi/drupal') 156 | assertOutput(cgiOut, 'GET /cgi/drupal 302') 157 | assertOutput(cgiErr, '') 158 | expect(initResponse.headers.get('location'), '/cgi/drupal/core/install.php') 159 | expect(initText).toContain('Redirecting to /cgi/drupal/core/install.php') 160 | 161 | const [, installText] = await doRequest(phpCgi, '/cgi/drupal/core/install.php') 162 | expect(installText).toContain('/cgi/drupal/core/install.php?langcode=en') 163 | 164 | const [, selectProfileText] = await doRequest(phpCgi, '/cgi/drupal/core/install.php?langcode=en') 165 | expect(selectProfileText).toContain('Select an installation profile') 166 | 167 | const [, , databaseConfigDocument] = await doRequest(phpCgi, '/cgi/drupal/core/install.php?langcode=en&profile=minimal') 168 | expect(databaseConfigDocument.title).toStrictEqual('Database configuration | Drupal') 169 | 170 | const [postDbConfigRes, postDbConfigText] = await doRequest( 171 | phpCgi, 172 | '/cgi/drupal/core/install.php?langcode=en&profile=minimal', 173 | 'POST', 174 | { 175 | form_build_id: databaseConfigDocument.querySelector('input[name="form_build_id"]').value, 176 | form_id: databaseConfigDocument.querySelector('input[name="form_id"]').value, 177 | op: databaseConfigDocument.querySelector('input[name="op"]').value 178 | } 179 | ) 180 | 181 | console.log(postDbConfigRes.headers) 182 | let location = new URL(postDbConfigRes.headers.get('location')) 183 | expect(location.pathname).toStrictEqual('/cgi/drupal/core/install.php') 184 | expect(postDbConfigText).toContain('Redirecting to http://localhost:3000/cgi/drupal/core/install.php') 185 | 186 | const [metaRefreshRes, metaRefreshText, metaRefreshDoc] = await doRequest(phpCgi, location.pathname + location.search) 187 | 188 | const [checkedRes] = await checkForMetaRefresh(phpCgi, metaRefreshRes, metaRefreshText, metaRefreshDoc) 189 | 190 | location = new URL(checkedRes.headers.get('location')) 191 | const [, , configureSiteDoc] = await doRequest(phpCgi, location.pathname + location.search) 192 | 193 | const [configureSiteRes, configureSiteText] = await doRequest( 194 | phpCgi, 195 | location.pathname + location.search, 196 | 'POST', 197 | { 198 | site_name: 'Node test', 199 | site_mail: 'admin@example.com', 200 | 'account[name]': 'admin', 201 | 'account[pass][pass1]': 'admin', 202 | 'account[pass][pass2]': 'admin', 203 | 'account[mail]': 'admin@example.com', 204 | date_default_timezone: 'America/Chicago', 205 | enable_update_status_module: 0, 206 | enable_update_status_emails: 0, 207 | form_build_id: configureSiteDoc.querySelector('input[name="form_build_id"]').value, 208 | form_id: configureSiteDoc.querySelector('input[name="form_id"]').value, 209 | op: configureSiteDoc.querySelector('input[name="op"]').value 210 | } 211 | ) 212 | location = new URL(configureSiteRes.headers.get('location')) 213 | expect(location.pathname).toStrictEqual('/cgi/drupal/') 214 | expect(configureSiteText).toContain('Redirecting to http://localhost:3000/cgi/drupal/') 215 | 216 | const [postInstallRes, , ] = await doRequest(phpCgi, location.pathname + location.search) 217 | location = new URL(postInstallRes.headers.get('location')) 218 | expect(location.pathname).toStrictEqual('/cgi/drupal/user/1') 219 | 220 | const [, finishedText, finishedDoc] = await doRequest(phpCgi, location.pathname + location.search) 221 | expect(finishedDoc.title).toStrictEqual('admin | Node test') 222 | expect(finishedText).toContain('/cgi/drupal/core/themes/stark/logo.svg') 223 | expect(finishedText).toContain('/cgi/drupal/core/modules/system/css/components/align.module.css') 224 | }) 225 | }, { 226 | timeout: 999999 227 | }) 228 | -------------------------------------------------------------------------------- /tests/install-worker.test.js: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, vi, afterEach} from 'vitest' 2 | import createFetchMock from 'vitest-fetch-mock'; 3 | import '@vitest/web-worker' 4 | 5 | global.navigator = { 6 | ...global.navigator, 7 | locks: { 8 | request: vi.fn((name, callback) => callback()) 9 | } 10 | }; 11 | 12 | vi.mock('../public/PhpWorker.mjs', () => { 13 | return { 14 | PhpWorker: vi.fn(() => { 15 | return { 16 | addEventListener: vi.fn(), 17 | unlink: vi.fn(), 18 | writeFile: vi.fn(), 19 | run: vi.fn(), 20 | analyzePath: vi.fn(() => ({ 21 | exists: false, 22 | })) 23 | } 24 | }) 25 | } 26 | }); 27 | 28 | const fetchMocker = createFetchMock(vi); 29 | fetchMocker.enableMocks(); 30 | fetchMocker.mockResponse( 31 | (req) => { 32 | if (req.url === '/foo/bar/baz.zip') { 33 | return Promise.resolve({ 34 | body: '', 35 | status: 200, 36 | }); 37 | } 38 | if (req.url.endsWith('install-site.phpcode')) { 39 | return Promise.resolve({ 40 | body: 'install-site.php', 41 | status: 200, 42 | }); 43 | } 44 | if (req.url.endsWith('init.phpcode')) { 45 | return Promise.resolve({ 46 | body: 'init.php', 47 | status: 200, 48 | }); 49 | } 50 | if (req.url.endsWith('login-admin.phpcode')) { 51 | return Promise.resolve({ 52 | body: 'login-admin.php', 53 | status: 200, 54 | }); 55 | } 56 | return Promise.reject(new Error('not mocked')); 57 | } 58 | ); 59 | 60 | function createWorker() { 61 | return new Worker(new URL('../public/install-worker.mjs?worker', import.meta.url)); 62 | } 63 | 64 | function runAction(sut, message, endAction) { 65 | return new Promise((resolve) => { 66 | sut.onmessage = ({data}) => { 67 | const {action} = data; 68 | if (action === endAction) { 69 | resolve(data) 70 | } 71 | } 72 | sut.postMessage(message); 73 | }); 74 | } 75 | 76 | describe('install worker', () => { 77 | afterEach(() => { 78 | vi.restoreAllMocks() 79 | fetchMocker.resetMocks() 80 | }) 81 | 82 | it('uses absolute urls for artifact', async () => { 83 | const sut = createWorker() 84 | await runAction( 85 | sut, 86 | { 87 | action: 'start', 88 | params: { 89 | flavor: 'foo', 90 | artifact: '/foo/bar/baz.zip', 91 | installParameters: { 92 | autoLogin: true, 93 | installType: 'automated', 94 | } 95 | } 96 | }, 97 | 'finished', 98 | ) 99 | 100 | expect(fetchMock.requests().length).toEqual(4); 101 | expect(fetchMock.requests()[0].url).toEqual('/foo/bar/baz.zip'); 102 | expect(fetchMock.requests()[1].url).toEqual('/assets/init.phpcode'); 103 | expect(fetchMock.requests()[2].url).toEqual('/assets/install-site.phpcode'); 104 | expect(fetchMock.requests()[3].url).toEqual('/assets/login-admin.phpcode'); 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /tests/trial-manager.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' 2 | import TrialManager, { defineTrialManagerElement } from "../public/trial-manager.mjs"; 3 | 4 | function createMockWorker() { 5 | const mock = { 6 | postMessage: vi.fn(), 7 | terminate: vi.fn() 8 | } 9 | vi.stubGlobal('Worker', vi.fn(() => mock)) 10 | return mock 11 | } 12 | 13 | function createTrialManager(flavor, artifact = 'drupal.zip') { 14 | const sut = new TrialManager(); 15 | sut.flavor = flavor; 16 | sut.artifact = artifact 17 | return sut; 18 | } 19 | 20 | describe('TrialManager', () => { 21 | beforeEach(() => { 22 | defineTrialManagerElement() 23 | }) 24 | afterEach(() => { 25 | vi.unstubAllGlobals() 26 | vi.restoreAllMocks() 27 | document.body.replaceChildren() 28 | }) 29 | 30 | it('custom element is defined', () => { 31 | createMockWorker() 32 | document.body.appendChild(createTrialManager()); 33 | expect(document.querySelector('trial-manager')).toBeTruthy(); 34 | }); 35 | 36 | it('changes state based on the mode', () => { 37 | createMockWorker() 38 | const sut = createTrialManager('bar') 39 | document.body.appendChild(sut); 40 | expect(sut.getInnerHTML()).toContain(' { 58 | vi.stubGlobal('confirm', vi.fn().mockImplementation(() => true)) 59 | const worker = createMockWorker() 60 | worker.postMessage.mockImplementation(({ action, params }) => { 61 | expect(['check_existing', buttonAction]).toContain(action) 62 | if (action === 'check_existing') { 63 | worker.onmessage({ 64 | data: { 65 | action: `check_existing_finished`, 66 | params: { 67 | exists: true, 68 | } 69 | } 70 | }) 71 | } else { 72 | expect(action).toBe(buttonAction) 73 | expect(params).toStrictEqual(expectedParams) 74 | worker.onmessage({ 75 | data: { 76 | action: workerAction, 77 | } 78 | }) 79 | } 80 | }) 81 | 82 | const sut = createTrialManager('bar', 'baz.zip') 83 | sut.mode = 'existing_session'; 84 | document.body.appendChild(sut); 85 | 86 | const channel = new BroadcastChannel('drupal-cgi-worker'); 87 | channel.postMessage({action: 'service_worker_ready'}) 88 | vi.waitFor(() => { 89 | expect(worker.postMessage).toHaveBeenCalledTimes(1) 90 | }) 91 | 92 | document.getElementById(buttonId).click() 93 | vi.waitFor(() => { 94 | expect(worker.postMessage).toHaveBeenCalledTimes(2) 95 | }) 96 | expect(sut.mode).toStrictEqual(endMode) 97 | }) 98 | 99 | it.each([ 100 | ['drupal'], 101 | ['starshot'] 102 | ])('checks for existing `%s` docroot when service_worker_ready', (flavor) => { 103 | const worker = createMockWorker() 104 | worker.postMessage.mockImplementation(({ action, params }) => { 105 | expect(action).toBe('check_existing') 106 | expect(params).toStrictEqual({ flavor }) 107 | }) 108 | 109 | document.body.appendChild(createTrialManager(flavor)); 110 | 111 | const channel = new BroadcastChannel('drupal-cgi-worker'); 112 | channel.postMessage({action: 'service_worker_ready'}) 113 | 114 | vi.waitFor(() => { 115 | expect(worker.postMessage).toHaveBeenCalledTimes(1) 116 | }) 117 | }) 118 | 119 | it('starts new session if one does not exist', () => { 120 | const worker = createMockWorker() 121 | worker.postMessage.mockImplementation(({ action, params }) => { 122 | expect(['check_existing', 'start']).toContain(action) 123 | if (action === 'check_existing') { 124 | worker.onmessage({ 125 | data: { 126 | action: `check_existing_finished`, 127 | params: { 128 | exists: false, 129 | } 130 | } 131 | }) 132 | } 133 | else { 134 | expect(params).toStrictEqual({ 135 | artifact: 'drupal.zip', 136 | flavor: 'foo', 137 | installParameters: { 138 | langcode: 'en', 139 | profile: 'standard', 140 | recipes: [], 141 | siteName: 'Try Drupal', 142 | skip: false, 143 | } 144 | }) 145 | worker.onmessage({ 146 | data: { 147 | action: `finished`, 148 | } 149 | }) 150 | } 151 | }) 152 | 153 | document.body.appendChild(createTrialManager('foo')); 154 | 155 | const channel = new BroadcastChannel('drupal-cgi-worker'); 156 | channel.postMessage({action: 'service_worker_ready'}) 157 | 158 | vi.waitFor(() => { 159 | expect(worker.postMessage).toHaveBeenCalledTimes(2) 160 | expect(window.location).toStrictEqual('/cgi/foo') 161 | }) 162 | }) 163 | 164 | it('terminates worker on removal', () => { 165 | const worker = createMockWorker() 166 | const sut = document.body.appendChild(createTrialManager()); 167 | document.body.removeChild(sut) 168 | expect(worker.terminate).toHaveBeenCalledTimes(1) 169 | }) 170 | 171 | it('stops worker on error', () => { 172 | const worker = createMockWorker() 173 | worker.postMessage.mockImplementation(({ action }) => { 174 | expect(action).toBe('stop') 175 | }) 176 | const sut = createTrialManager('foo'); 177 | 178 | worker.onmessage({ 179 | data: { 180 | action: 'status', 181 | type: 'error', 182 | message: 'barbaz', 183 | } 184 | }) 185 | expect(worker.postMessage).toHaveBeenCalledTimes(1) 186 | expect(sut.message).toStrictEqual('barbaz') 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | import {dirname} from "node:path"; 2 | import {PhpNode} from "php-wasm/PhpNode.mjs"; 3 | import {PhpBase} from "php-wasm/PhpBase.mjs"; 4 | import PhpBinary from "php-wasm/php-node.mjs"; 5 | import {PhpCgiNode} from "php-cgi-wasm/PhpCgiNode.mjs"; 6 | import fs from "node:fs"; 7 | import {expect} from "vitest"; 8 | import crypto from "node:crypto"; 9 | import {Window} from "happy-dom"; 10 | import querystring from "node:querystring"; 11 | 12 | export const rootPath = dirname(import.meta.dirname); 13 | export const rootFixturePath = rootPath + '/tests/fixtures' 14 | 15 | const sharedLibs = [ 16 | { 17 | name: `php${PhpNode.phpVersion}-zip.so`, 18 | url: `${rootPath}/node_modules/php-wasm-libzip/php${PhpNode.phpVersion}-zip.so`, 19 | ini: true 20 | }, 21 | { 22 | name: `libzip.so`, 23 | url: `${rootPath}/node_modules/php-wasm-libzip/libzip.so`, 24 | ini: false 25 | }, 26 | { 27 | name: `php${PhpNode.phpVersion}-zlib.so`, 28 | url: `${rootPath}/node_modules/php-wasm-zlib/php${PhpNode.phpVersion}-zlib.so`, 29 | ini: true 30 | }, 31 | { 32 | name: `libz.so`, 33 | url: `${rootPath}/node_modules/php-wasm-zlib/libz.so`, 34 | ini: false 35 | }, 36 | { 37 | name: `php${PhpNode.phpVersion}-dom.so`, 38 | url: `${rootPath}/node_modules/php-wasm-dom/php${PhpNode.phpVersion}-dom.so`, 39 | ini: true 40 | }, 41 | { 42 | name: `php${PhpNode.phpVersion}-simplexml.so`, 43 | url: `${rootPath}/node_modules/php-wasm-simplexml/php${PhpNode.phpVersion}-simplexml.so`, 44 | ini: true 45 | }, 46 | { 47 | name: `php${PhpNode.phpVersion}-xml.so`, 48 | url: `${rootPath}/node_modules/php-wasm-xml/php${PhpNode.phpVersion}-xml.so`, 49 | ini: true 50 | }, 51 | { 52 | name: `php${PhpNode.phpVersion}-gd.so`, 53 | url: `${rootPath}/node_modules/php-wasm-gd/php${PhpNode.phpVersion}-gd.so`, 54 | ini: true 55 | }, 56 | { 57 | name: `libfreetype.so`, 58 | url: `${rootPath}/node_modules/php-wasm-gd/libfreetype.so`, 59 | ini: false 60 | }, 61 | { 62 | name: `libjpeg.so`, 63 | url: `${rootPath}/node_modules/php-wasm-gd/libjpeg.so`, 64 | ini: false 65 | }, 66 | { 67 | name: `libpng.so`, 68 | url: `${rootPath}/node_modules/php-wasm-gd/libpng.so`, 69 | ini: false 70 | }, 71 | { 72 | name: `libwebp.so`, 73 | url: `${rootPath}/node_modules/php-wasm-gd/libwebp.so`, 74 | ini: false 75 | }, 76 | { 77 | name: `php${PhpNode.phpVersion}-pdo-sqlite.so`, 78 | url: `${rootPath}/node_modules/php-wasm-sqlite/php${PhpNode.phpVersion}-pdo-sqlite.so`, 79 | ini: true 80 | }, 81 | { 82 | name: `php${PhpNode.phpVersion}-sqlite.so`, 83 | url: `${rootPath}/node_modules/php-wasm-sqlite/php${PhpNode.phpVersion}-sqlite.so`, 84 | ini: true 85 | }, 86 | { 87 | name: `libsqlite3.so`, 88 | url: `${rootPath}/node_modules/php-wasm-sqlite/libsqlite3.so`, 89 | ini: false 90 | }, 91 | { 92 | name: `php${PhpNode.phpVersion}-iconv.so`, 93 | url: `${rootPath}/node_modules/php-wasm-iconv/php${PhpNode.phpVersion}-iconv.so`, 94 | ini: true 95 | }, 96 | { 97 | name: `libiconv.so`, 98 | url: `${rootPath}/node_modules/php-wasm-iconv/libiconv.so`, 99 | ini: false 100 | }, 101 | { 102 | name: `php${PhpNode.phpVersion}-mbstring.so`, 103 | url: `${rootPath}/node_modules/php-wasm-mbstring/php${PhpNode.phpVersion}-mbstring.so`, 104 | ini: true 105 | }, 106 | { 107 | name: `libonig.so`, 108 | url: `${rootPath}/node_modules/php-wasm-mbstring/libonig.so`, 109 | ini: false 110 | }, 111 | ] 112 | 113 | // works around PhpNode and PhpCgiNode locate file issues. 114 | const locateFile = () => undefined; 115 | 116 | export function createPhp({ configFixturePath, persistFixturePath }) { 117 | const php = new PhpBase(PhpBinary, { 118 | persist: [ 119 | {mountPath: '/persist', localPath: persistFixturePath}, 120 | {mountPath: '/config', localPath: configFixturePath}, 121 | ], 122 | locateFile, 123 | sharedLibs 124 | }); 125 | const stdOut = [], stdErr = []; 126 | php.addEventListener('output', (event) => event.detail.forEach(line => void (stdOut.push(line)))); 127 | php.addEventListener('error', (event) => event.detail.forEach(line => void (stdErr.push(line)))); 128 | return [stdOut, stdErr, php] 129 | } 130 | 131 | export function createCgiPhp({ configFixturePath, persistFixturePath }) { 132 | const stdOut = [], stdErr = []; 133 | 134 | const php = new PhpCgiNode({ 135 | persist: [ 136 | {mountPath: '/persist', localPath: persistFixturePath}, 137 | {mountPath: '/config', localPath: configFixturePath}, 138 | ], 139 | locateFile, 140 | sharedLibs, 141 | env: { 142 | HTTP_USER_AGENT: 'node' 143 | }, 144 | docroot: '/persist/drupal/web', 145 | vHosts: [ 146 | { 147 | pathPrefix: '/cgi/drupal', 148 | directory: '/persist/drupal/web', 149 | entrypoint: 'index.php', 150 | } 151 | ], 152 | /** 153 | * 154 | * @param {Request} request 155 | * @param {Response} response 156 | */ 157 | onRequest(request, response) { 158 | const url = new URL(request.url); 159 | stdOut.push(`${request.method} ${url.pathname}${url.search} ${response.status}`) 160 | }, 161 | notFound(request) { 162 | const url = new URL(request.url); 163 | stdErr.push(`${request.method} ${url.pathname} 404`) 164 | return new Response(`

404

${request.url} not found`, { 165 | status: 404, 166 | headers: {"Content-Type": "text/html"}, 167 | }); 168 | } 169 | }) 170 | php.cookies.set('big_pipe_nojs', '1') 171 | return [stdOut, stdErr, php] 172 | } 173 | 174 | export async function runPhpCode(php, path) { 175 | const initPhpCode = fs.readFileSync(path).toString() 176 | await php.binary; 177 | await php.run(initPhpCode); 178 | } 179 | 180 | export function assertOutput(std, expected) { 181 | expect(std.join('').trim()).toStrictEqual(expected) 182 | } 183 | 184 | export function setupFixturePaths(context) { 185 | const testRoot = rootFixturePath + '/' + crypto.randomBytes(5).toString('hex'); 186 | context.configFixturePath = testRoot + '/config' 187 | context.persistFixturePath = testRoot + '/persist' 188 | context.testRoot = testRoot 189 | fs.mkdirSync(context.configFixturePath, { recursive: true }) 190 | fs.mkdirSync(context.persistFixturePath, { recursive: true }) 191 | } 192 | 193 | export function cleanupFixturePaths({ configFixturePath, persistFixturePath, testRoot }) { 194 | if (fs.existsSync(`${configFixturePath}/flavor.txt`)) { 195 | const flavorValue = fs.readFileSync(`${configFixturePath}/flavor.txt`).toString() 196 | if (fs.existsSync(`${persistFixturePath}/${flavorValue}`)) { 197 | fs.chmodSync(`${persistFixturePath}/${flavorValue}/web/sites/default`, 0o777) 198 | fs.rmSync(`${persistFixturePath}/${flavorValue}`, { recursive: true, force: true }) 199 | } 200 | fs.unlinkSync(`${configFixturePath}/flavor.txt`) 201 | } 202 | if (fs.existsSync(`${persistFixturePath}/artifact.zip`)) { 203 | fs.unlinkSync(`${persistFixturePath}/artifact.zip`) 204 | } 205 | fs.rmSync(testRoot, { recursive: true, force: true }) 206 | } 207 | 208 | export function writeFlavorTxt(configFixturePath) { 209 | fs.writeFileSync(`${configFixturePath}/flavor.txt`, 'drupal') 210 | } 211 | 212 | export function writeInstallParams(configFixturePath, params) { 213 | fs.writeFileSync(`${configFixturePath}/drupal-install-params.json`, JSON.stringify({ 214 | langcode: 'en', 215 | skip: false, 216 | siteName: 'test', 217 | profile: 'standard', 218 | recipes: [], 219 | ...params 220 | })) 221 | } 222 | 223 | export function copyArtifactFixture(persistFixturePath, name) { 224 | fs.copyFileSync( 225 | `${rootFixturePath}/${name}`, 226 | `${persistFixturePath}/artifact.zip` 227 | ) 228 | } 229 | export function copyExistingBuildFixture(persistFixturePath, name) { 230 | fs.cpSync(`${rootFixturePath}/${name}`, `${persistFixturePath}/drupal`, { 231 | recursive: true 232 | }) 233 | } 234 | 235 | export async function doRequest(phpCgi, url, method = 'GET', body = null) { 236 | const request = { 237 | connection: { 238 | encrypted: false, 239 | }, 240 | method, 241 | url, 242 | headers: { 243 | host: globalThis.location.host 244 | }, 245 | }; 246 | if (body) { 247 | const buffer = new TextEncoder().encode(querystring.stringify(body)); 248 | request.body = new ReadableStream({ 249 | start(controller) { 250 | controller.enqueue(buffer); 251 | controller.close(); 252 | } 253 | }) 254 | } 255 | const response = await phpCgi.request(request) 256 | const text = await response.text() 257 | const document = (new Window()).document 258 | document.write(text) 259 | return [response, text, document] 260 | } 261 | 262 | export async function checkForMetaRefresh(phpCgi, response, text, document) { 263 | if (response.status === 200) { 264 | const meta = document.querySelector('meta[http-equiv="Refresh"]'); 265 | if (meta) { 266 | const match = meta.content.match(/\d+;\s*URL=\'?(?[^\']*)/i) 267 | if (match) { 268 | const url = match.groups.url 269 | const [newRes, newText, newDocument] = await doRequest(phpCgi, url) 270 | return await checkForMetaRefresh(phpCgi, newRes, newText, newDocument) 271 | } 272 | } 273 | } 274 | return [response, text, document] 275 | } 276 | 277 | /** 278 | * Verifies that the `sites/default` directory is writeable. 279 | * 280 | * @param {string} persistFixturePath 281 | */ 282 | export function assertSitesDefaultDirectoryPermissions(persistFixturePath) { 283 | const stat = fs.statSync(`${persistFixturePath}/drupal/web/sites/default`) 284 | expect(stat.mode & 0o777).toStrictEqual(0o755) 285 | 286 | const statSettings = fs.statSync(`${persistFixturePath}/drupal/web/sites/default/settings.php`) 287 | expect(statSettings.mode & 0o777).toStrictEqual(0o644) 288 | } 289 | 290 | /** 291 | * Asserts the location header of a response. 292 | * 293 | * @param {Response} response 294 | * @param {string} pathname 295 | * @param {string} search 296 | */ 297 | export function assertLocationHeader(response, pathname, search) { 298 | expect(response.headers.has('location')).toBeTruthy() 299 | let location; 300 | try { 301 | location = new URL(response.headers.get('location'), globalThis.location.toString()) 302 | } catch (e) { 303 | console.error(e) 304 | expect(response.headers.get('location')).toStrictEqual(pathname + search) 305 | } 306 | expect(location.pathname).toStrictEqual(pathname) 307 | expect(location.search).toStrictEqual(search) 308 | return location 309 | } 310 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'happy-dom', 6 | include: [ 7 | 'tests/**' 8 | ], 9 | exclude: [ 10 | ...configDefaults.exclude, 11 | 'tests/fixtures/**', 12 | 'tests/utils.js' 13 | ], 14 | coverage: { 15 | provider: 'v8', 16 | reporter: ['text'], 17 | include: [ 18 | 'public/drupal-cgi-worker.mjs', 19 | 'public/install-worker.mjs', 20 | 'public/service-worker.mjs', 21 | 'public/trial-manager.mjs' 22 | ], 23 | } 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /workers.webpack.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | mode: "production", 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.mjs$/, 9 | exclude: /node_modules/, 10 | use: { loader: "babel-loader" } 11 | } 12 | ] 13 | }, 14 | entry: { 15 | "service-worker": "./public/service-worker.mjs", 16 | }, 17 | output: { 18 | path: path.resolve(__dirname, "public"), 19 | filename: "[name].js" 20 | }, 21 | target: "webworker" 22 | }; 23 | --------------------------------------------------------------------------------