diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..0b51f937dc --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +src/node/wordpress/**/* +node-php.js diff --git a/.eslintrc.js b/.eslintrc.js index d21dde9b9f..70c9f9fb72 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,11 @@ module.exports = { browser: true, es2021: true, }, + settings: { + 'react': { + 'version': '999.99.99' // Prevent eslint from complaining (we don't use react). + } + }, extends: [ 'eslint:recommended', 'plugin:@wordpress/eslint-plugin/recommended', @@ -21,6 +26,11 @@ module.exports = { 'no-inner-declaration': 0, 'no-use-before-define': 'off', 'react/prop-types': 0, + 'no-console': 0, + 'no-empty': 0, + 'no-async-promise-executor': 0, + 'no-constant-condition': 0, + 'no-nested-ternary': 0, 'jsx-a11y/click-events-have-key-events': 0, 'jsx-a11y/no-static-element-interactions': 0, }, diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..e7a7dcc479 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..0b51f937dc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +src/node/wordpress/**/* +node-php.js diff --git a/dist-web/app.js b/dist-web/app.js index cd1658c801..38cf8bae06 100644 --- a/dist-web/app.js +++ b/dist-web/app.js @@ -38,7 +38,7 @@ } // src/web/library.js - var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, 50)); + var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function runWordPress({ wasmWorkerBackend: wasmWorkerBackend2, wasmWorkerUrl: wasmWorkerUrl2, @@ -64,31 +64,37 @@ async function registerServiceWorker({ url, onRequest, scope }) { if (!navigator.serviceWorker) { alert("Service workers are not supported in this browser."); - throw new Exception("Service workers are not supported in this browser."); + throw new Error("Service workers are not supported in this browser."); } await navigator.serviceWorker.register(url); const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker`); - serviceWorkerChannel.addEventListener("message", async function onMessage(event) { - if (scope && event.data.scope !== scope) { - return; - } - console.debug(`[Main] "${event.data.type}" message received from a service worker`); - let result; - if (event.data.type === "request" || event.data.type === "httpRequest") { - result = await onRequest(event.data.request); - } else { - throw new Error(`[Main] Unexpected message received from the service-worker: "${event.data.type}"`); - } - if (event.data.messageId) { - serviceWorkerChannel.postMessage( - responseTo( - event.data.messageId, - result - ) + serviceWorkerChannel.addEventListener( + "message", + async function onMessage(event) { + if (scope && event.data.scope !== scope) { + return; + } + console.debug( + `[Main] "${event.data.type}" message received from a service worker` ); + let result; + if (event.data.type === "request" || event.data.type === "httpRequest") { + result = await onRequest(event.data.request); + } else { + throw new Error( + `[Main] Unexpected message received from the service-worker: "${event.data.type}"` + ); + } + if (event.data.messageId) { + serviceWorkerChannel.postMessage( + responseTo(event.data.messageId, result) + ); + } + console.debug(`[Main] "${event.data.type}" message processed`, { + result + }); } - console.debug(`[Main] "${event.data.type}" message processed`, { result }); - }); + ); navigator.serviceWorker.startMessages(); await sleep(0); const wordPressDomain = new URL(url).origin; @@ -98,7 +104,11 @@ window.location.reload(); } } - async function createWordPressWorker({ backend, wordPressSiteUrl: wordPressSiteUrl2, scope }) { + async function createWordPressWorker({ + backend, + wordPressSiteUrl: wordPressSiteUrl2, + scope + }) { while (true) { try { await backend.sendMessage({ type: "is_alive" }, 50); @@ -135,14 +145,16 @@ const backend = backends[key]; if (!backend) { const availableKeys = Object.keys(backends).join(", "); - throw new Error(`Unknown worker backend: "${key}". Choices: ${availableKeys}`); + throw new Error( + `Unknown worker backend: "${key}". Choices: ${availableKeys}` + ); } return backend(url); } function webWorkerBackend(workerURL) { const worker = new Worker(workerURL); return { - sendMessage: async function(message, timeout = DEFAULT_REPLY_TIMEOUT) { + async sendMessage(message, timeout = DEFAULT_REPLY_TIMEOUT) { const messageId = postMessageExpectReply(worker, message); const response = await awaitReply(worker, messageId, timeout); return response; @@ -153,7 +165,7 @@ const worker = new SharedWorker(workerURL); worker.port.start(); return { - sendMessage: async function(message, timeout = DEFAULT_REPLY_TIMEOUT) { + async sendMessage(message, timeout = DEFAULT_REPLY_TIMEOUT) { const messageId = postMessageExpectReply(worker.port, message); const response = await awaitReply(worker.port, messageId, timeout); return response; @@ -166,8 +178,12 @@ iframe.style.display = "none"; document.body.appendChild(iframe); return { - sendMessage: async function(message, timeout = DEFAULT_REPLY_TIMEOUT) { - const messageId = postMessageExpectReply(iframe.contentWindow, message, "*"); + async sendMessage(message, timeout = DEFAULT_REPLY_TIMEOUT) { + const messageId = postMessageExpectReply( + iframe.contentWindow, + message, + "*" + ); const response = await awaitReply(window, messageId, timeout); return response; } diff --git a/dist-web/iframe-worker.html b/dist-web/iframe-worker.html index affa0f6c30..f5b81a5f13 100644 --- a/dist-web/iframe-worker.html +++ b/dist-web/iframe-worker.html @@ -1,9 +1,7 @@ - - - - - + + + + - diff --git a/dist-web/service-worker.js b/dist-web/service-worker.js index ef18e27a02..ef1b74f454 100644 --- a/dist-web/service-worker.js +++ b/dist-web/service-worker.js @@ -48,7 +48,9 @@ event.preventDefault(); return event.respondWith( new Promise(async (accept) => { - console.log(`[ServiceWorker] Serving request: ${url.pathname}?${url.search}`); + console.log( + `[ServiceWorker] Serving request: ${url.pathname}?${url.search}` + ); console.log({ isWpOrgRequest, isPHPRequest }); const post = await parsePost(event.request); const requestHeaders = {}; @@ -67,20 +69,23 @@ headers: requestHeaders } }; - console.log("[ServiceWorker] Forwarding a request to the main app", { message }); + console.log("[ServiceWorker] Forwarding a request to the main app", { + message + }); const messageId = postMessageExpectReply(broadcastChannel, message); wpResponse = await awaitReply(broadcastChannel, messageId); - console.log("[ServiceWorker] Response received from the main app", { wpResponse }); + console.log("[ServiceWorker] Response received from the main app", { + wpResponse + }); } catch (e) { console.error(e); throw e; } - accept(new Response( - wpResponse.body, - { + accept( + new Response(wpResponse.body, { headers: wpResponse.headers - } - )); + }) + ); }) ); } @@ -89,7 +94,9 @@ const scopedUrl = url + ""; url.pathname = "/" + url.pathname.split("/").slice(2).join("/"); const serverUrl = url + ""; - console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); + console.log( + `[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}` + ); event.preventDefault(); return event.respondWith( new Promise(async (accept) => { @@ -103,7 +110,7 @@ console.log(`[ServiceWorker] Ignoring a request to ${event.request.url}`); }); async function cloneRequest(request, overrides) { - const body = ["GET", "HEAD"].includes(request.method) || "body" in overrides ? void 0 : await r.blob(); + const body = ["GET", "HEAD"].includes(request.method) || "body" in overrides ? void 0 : await request.blob(); return new Request(overrides.url || request.url, { body, method: request.method, diff --git a/dist-web/wasm-worker.js b/dist-web/wasm-worker.js index 2d145fa2c0..1d4315bf56 100644 --- a/dist-web/wasm-worker.js +++ b/dist-web/wasm-worker.js @@ -462,13 +462,19 @@ ADMIN; this.setCookies(response.headers["set-cookie"]); } if (this.config.handleRedirects && response.headers.location && redirects < this.config.maxRedirects) { - const parsedUrl = new URL(response.headers.location[0], this.wp.ABSOLUTE_URL); - return this.request({ - path: parsedUrl.pathname, - method: "GET", - _GET: parsedUrl.search, - headers: {} - }, redirects + 1); + const parsedUrl = new URL( + response.headers.location[0], + this.wp.ABSOLUTE_URL + ); + return this.request( + { + path: parsedUrl.pathname, + method: "GET", + _GET: parsedUrl.search, + headers: {} + }, + redirects + 1 + ); } return response; } @@ -533,20 +539,14 @@ ADMIN; } else if (IS_WEBWORKER) { phpLoaderScriptName = "/php-webworker.js"; onmessage = (event) => { - handleMessageEvent( - event, - postMessage - ); + handleMessageEvent(event, postMessage); }; } else if (IS_SHARED_WORKER) { phpLoaderScriptName = "/php-webworker.js"; self.onconnect = (e) => { const port = e.ports[0]; port.addEventListener("message", (event) => { - handleMessageEvent( - event, - (r) => port.postMessage(r) - ); + handleMessageEvent(event, (r) => port.postMessage(r)); }); port.start(); }; @@ -555,12 +555,7 @@ ADMIN; console.debug(`[WASM Worker] "${event.data.type}" event received`, event); const result = await generateResponseForMessage(event.data); if (event.data.messageId) { - respond( - responseTo( - event.data.messageId, - result - ) - ); + respond(responseTo(event.data.messageId, result)); } console.debug(`[WASM Worker] "${event.data.type}" event processed`); } @@ -584,7 +579,9 @@ ADMIN; _GET: parsedUrl.search }); } - console.debug(`[WASM Worker] "${message.type}" event has no handler, short-circuiting`); + console.debug( + `[WASM Worker] "${message.type}" event has no handler, short-circuiting` + ); } async function initWPBrowser(siteUrl) { console.log("[WASM Worker] Before wp.init()"); @@ -602,13 +599,15 @@ ADMIN; importScripts("/wp.js"); }); PHPModule.FS.mkdirTree("/usr/local/etc"); - PHPModule.FS.writeFile("/usr/local/etc/php.ini", `[PHP] - - error_reporting = E_ERROR | E_PARSE - display_errors = 1 - html_errors = 1 - display_startup_errors = On - `); + PHPModule.FS.writeFile( + "/usr/local/etc/php.ini", + `[PHP] +error_reporting = E_ERROR | E_PARSE +display_errors = 1 +html_errors = 1 +display_startup_errors = On + ` + ); const wp = new WordPress(php); await wp.init(siteUrl); console.log("[WASM Worker] After wp.init()"); diff --git a/dist-web/wordpress.html b/dist-web/wordpress.html index ffecdd46b4..e116bd45de 100644 --- a/dist-web/wordpress.html +++ b/dist-web/wordpress.html @@ -1,11 +1,13 @@ - - WordPress code embed! - - - - - + + WordPress code embed! + + + + + - diff --git a/package.json b/package.json index 734aa5eb09..4098174b33 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,17 @@ "clean": "npm-run-all --parallel clean:*", "clean:php": "rm -rf dist-web/wasm-build/php/docker-output/*", "clean:wp": "rm -rf dist-web/wasm-build/wordpress/docker-output/* dist-web/wasm-build/wordpress/preload/*", - "test": "echo \"Error: no test specified\" && exit 1" + "format": "prettier --write src", + "lint:js": "eslint \"./src/**/*.{js,mjs,ts}\"", + "lint:js:fix": "npm run lint:js -- --fix", + "test": "echo \"Error: no test specified\" && exit 1", + "prepare": "husky install" + }, + "lint-staged": { + "src/**/*": [ + "npx prettier --write --ignore-unknown", + "npx eslint --fix" + ] }, "author": "Adam Zielinski", "license": "ISC", @@ -45,7 +55,10 @@ "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-react": "^7.31.1", "eslint-plugin-react-hooks": "^4.6.0", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", "live-server": "^1.2.2", - "npm-run-all": "^4.1.5" + "npm-run-all": "^4.1.5", + "prettier": "^2.7.1" } } diff --git a/src/node/bootstrap.mjs b/src/node/bootstrap.mjs index 48806950b4..a234d4b434 100644 --- a/src/node/bootstrap.mjs +++ b/src/node/bootstrap.mjs @@ -1,83 +1,88 @@ -import fs from 'fs'; -import PHP from './node-php.js'; -import path from 'path'; +import fs from "fs"; +import PHP from "./node-php.js"; +import path from "path"; -import PHPWrapper from '../shared/php-wrapper.mjs'; -import WordPress from '../shared/wordpress.mjs'; +import PHPWrapper from "../shared/php-wrapper.mjs"; +import WordPress from "../shared/wordpress.mjs"; -import { fileURLToPath } from 'node:url'; -__dirname = __dirname || fileURLToPath( new URL( '.', import.meta.url ) ); +import { fileURLToPath } from "node:url"; +// eslint-disable-next-line no-global-assign +__dirname = __dirname || fileURLToPath(new URL(".", import.meta.url)); -export async function createWordPressClient( options = {} ) { - options = { - preInit() { }, - phpWasmPath: `./node-php.wasm`, - etcPath: path.join( __dirname, 'etc' ), - wpPath: path.join( __dirname, 'wordpress' ), - ...options, - }; - const php = new PHPWrapper(); - await php.init( PHP, { - locateFile() { - return path.join(__dirname, options.phpWasmPath); - }, - onPreInit( FS, NODEFS ) { - FS.mkdirTree('/usr/local/etc'); - FS.mount( NODEFS, { root: options.etcPath }, '/usr/local/etc' ); - FS.mkdirTree( '/preload/wordpress' ); - FS.mount( NODEFS, { root: options.wpPath }, '/preload/wordpress' ); - options.preInit( FS, NODEFS ); - }, - } ); - return new WordPress( php ); +export async function createWordPressClient(options = {}) { + options = { + preInit() {}, + phpWasmPath: `./node-php.wasm`, + etcPath: path.join(__dirname, "etc"), + wpPath: path.join(__dirname, "wordpress"), + ...options, + }; + const php = new PHPWrapper(); + await php.init(PHP, { + locateFile() { + return path.join(__dirname, options.phpWasmPath); + }, + onPreInit(FS, NODEFS) { + FS.mkdirTree("/usr/local/etc"); + FS.mount(NODEFS, { root: options.etcPath }, "/usr/local/etc"); + FS.mkdirTree("/preload/wordpress"); + FS.mount(NODEFS, { root: options.wpPath }, "/preload/wordpress"); + options.preInit(FS, NODEFS); + }, + }); + return new WordPress(php); } -export async function install( browser, siteUrl, options = {} ) { - options = { - siteTitle: 'WordPress', - username: 'admin', - password: 'password', - email: 'admin@localhost.com', - ...options, - }; +export async function install(browser, siteUrl, options = {}) { + options = { + siteTitle: "WordPress", + username: "admin", + password: "password", + email: "admin@localhost.com", + ...options, + }; - await browser.request( { - path: '/wp-admin/install.php', - } ); + await browser.request({ + path: "/wp-admin/install.php", + }); - return await browser.request( { - path: '/wp-admin/install.php', - method: 'POST', - headers: { - siteUrl, - 'content-type': 'application/x-www-form-urlencoded', - }, - _GET: '?step=2', - _POST: { - weblog_title: options.siteTitle, - user_name: options.username, - admin_password: options.password, - admin_password2: options.password, - admin_email: options.email, - Submit: 'Install WordPress', - language: '', - }, - } ); + return await browser.request({ + path: "/wp-admin/install.php", + method: "POST", + headers: { + siteUrl, + "content-type": "application/x-www-form-urlencoded", + }, + _GET: "?step=2", + _POST: { + weblog_title: options.siteTitle, + user_name: options.username, + admin_password: options.password, + admin_password2: options.password, + admin_email: options.email, + Submit: "Install WordPress", + language: "", + }, + }); } -export async function login( browser, username = 'admin', password = 'password' ) { - await browser.request( { - path: '/wp-login.php', - } ); - await browser.request( { - path: '/wp-login.php', - method: 'POST', - _POST: { - log: username, - pwd: password, - rememberme: 'forever', - }, - } ); +export async function login( + browser, + username = "admin", + password = "password" +) { + await browser.request({ + path: "/wp-login.php", + }); + await browser.request({ + path: "/wp-login.php", + method: "POST", + _POST: { + log: username, + pwd: password, + rememberme: "forever", + }, + }); } /** @@ -88,23 +93,26 @@ export async function login( browser, username = 'admin', password = 'password' * @param {string} base64FilePath * @param {string} wpPath */ -export function initDatabaseFromBase64File( base64FilePath, wpPath = __dirname + '/wordpress' ) { - const wpdbFilePath = path.join( wpPath, '/wp-content/database/.ht.sqlite' ); - try { - fs.unlinkSync( wpdbFilePath ); - } catch ( e ) {} - base64DecodeFile( base64FilePath, wpdbFilePath ); +export function initDatabaseFromBase64File( + base64FilePath, + wpPath = __dirname + "/wordpress" +) { + const wpdbFilePath = path.join(wpPath, "/wp-content/database/.ht.sqlite"); + try { + fs.unlinkSync(wpdbFilePath); + } catch (e) {} + base64DecodeFile(base64FilePath, wpdbFilePath); } -function base64DecodeFile( inputFile, outputFile ) { - const base64 = fs.readFileSync( inputFile, 'utf8' ); - const data = Buffer.from( base64, 'base64' ); - fs.writeFileSync( outputFile, data ); +function base64DecodeFile(inputFile, outputFile) { + const base64 = fs.readFileSync(inputFile, "utf8"); + const data = Buffer.from(base64, "base64"); + fs.writeFileSync(outputFile, data); } -export async function encodeSqliteDbFile( wp, outfile = 'db.sqlite' ) { - const file = await wp.php.run( ` { - try { - const [ relativeHostPath, relativeWasmPath ] = mount.split( ':' ); - const absoluteHostPath = path.isAbsolute( relativeHostPath ) ? relativeHostPath : path.resolve( process.cwd(), relativeHostPath ); - const absoluteWasmPath = path.isAbsolute( relativeWasmPath ) ? relativeWasmPath : path.join( '/preload/wordpress', relativeWasmPath ); - return { absoluteHostPath, absoluteWasmPath, relativeHostPath, relativeWasmPath }; - } catch ( e ) { - console.error( `Failed to mount ${ mount }` ); - process.exit( 0 ); - } - } ); - const wp = await createWordPressClient( { - preInit( FS, NODE_FS ) { - for ( const { absoluteHostPath, absoluteWasmPath } of mounts ) { - FS.mkdirTree( absoluteWasmPath ); - FS.mount( NODE_FS, { root: absoluteHostPath }, absoluteWasmPath ); - } - }, - } ); + const mounts = argv.mount.map((mount) => { + try { + const [relativeHostPath, relativeWasmPath] = mount.split(":"); + const absoluteHostPath = path.isAbsolute(relativeHostPath) + ? relativeHostPath + : path.resolve(process.cwd(), relativeHostPath); + const absoluteWasmPath = path.isAbsolute(relativeWasmPath) + ? relativeWasmPath + : path.join("/preload/wordpress", relativeWasmPath); + return { + absoluteHostPath, + absoluteWasmPath, + relativeHostPath, + relativeWasmPath, + }; + } catch (e) { + console.error(`Failed to mount ${mount}`); + return process.exit(0); + } + }); + const wp = await createWordPressClient({ + preInit(FS, NODE_FS) { + for (const { absoluteHostPath, absoluteWasmPath } of mounts) { + FS.mkdirTree(absoluteWasmPath); + FS.mount(NODE_FS, { root: absoluteHostPath }, absoluteWasmPath); + } + }, + }); - const browser = new WPBrowser( wp ); - return await startExpressServer( browser, argv.port, { - mounts, - initialUrl: argv.initialUrl, - } ); + const browser = new WPBrowser(wp); + return await startExpressServer(browser, argv.port, { + mounts, + initialUrl: argv.initialUrl, + }); } -const nodePath = path.resolve( process.argv[ 1 ] ); -const modulePath = __dirname ? `${__filename}` : path.resolve( fileURLToPath( import.meta.url ) ); +const nodePath = path.resolve(process.argv[1]); +const modulePath = __dirname + ? `${__filename}` + : path.resolve(fileURLToPath(import.meta.url)); const isRunningDirectlyViaCLI = nodePath === modulePath; -if ( isRunningDirectlyViaCLI ) { - const argv = yargs( process.argv.slice( 2 ) ) - .command( 'server', 'Starts a WordPress server' ) - .options( { - port: { - type: 'number', - default: 9854, - describe: 'Port to listen on', - }, - initialUrl: { - type: 'string', - default: '/wp-admin/index.php', - describe: 'The first URL to navigate to.', - }, - mount: { - type: 'array', - default: [], - describe: 'Paths to mount in the WASM runtime filesystem. Format: :. Based on the current working directory on host, and WordPress root directory in the WASM runtime.', - }, - } ) - .help() - .alias( 'help', 'h' ) - .argv; - command( argv ); +if (isRunningDirectlyViaCLI) { + const argv = yargs(process.argv.slice(2)) + .command("server", "Starts a WordPress server") + .options({ + port: { + type: "number", + default: 9854, + describe: "Port to listen on", + }, + initialUrl: { + type: "string", + default: "/wp-admin/index.php", + describe: "The first URL to navigate to.", + }, + mount: { + type: "array", + default: [], + describe: + "Paths to mount in the WASM runtime filesystem. Format: :. Based on the current working directory on host, and WordPress root directory in the WASM runtime.", + }, + }) + .help() + .alias("help", "h").argv; + command(argv); } diff --git a/src/node/express-server.mjs b/src/node/express-server.mjs index 41a0dae74e..8055887e6f 100644 --- a/src/node/express-server.mjs +++ b/src/node/express-server.mjs @@ -1,96 +1,97 @@ -import express from 'express'; -import cookieParser from 'cookie-parser'; -import bodyParser from 'body-parser'; +import express from "express"; +import cookieParser from "cookie-parser"; +import bodyParser from "body-parser"; -import path from 'path'; -import { fileURLToPath } from 'node:url'; -import { existsSync } from 'node:fs'; -import { login } from './bootstrap.mjs'; +import path from "path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { login } from "./bootstrap.mjs"; -__dirname = __dirname || fileURLToPath( new URL( '.', import.meta.url ) ); +// eslint-disable-next-line no-global-assign +__dirname = __dirname || fileURLToPath(new URL(".", import.meta.url)); -export async function startExpressServer( browser, port, options = {} ) { - options = { - mounts: {}, - initialUrl: '/wp-admin/index.php', - ...options, - }; +export async function startExpressServer(browser, port, options = {}) { + options = { + mounts: {}, + initialUrl: "/wp-admin/index.php", + ...options, + }; - const app = express(); - app.use( cookieParser() ); - app.use( bodyParser.urlencoded( { extended: true } ) ); - app.all( '*', async ( req, res ) => { - if ( ! browser.wp.initialized ) { - if ( req.query?.domain ) { - await browser.wp.init( - new URL( req.query.domain ).toString(), - { useFetchForRequests: true } - ); - await login( browser, 'admin', 'password' ); - res.status( 302 ); - res.setHeader( 'location', options.initialUrl ); - res.end(); - } else { - res.setHeader( 'content-type', 'text/html' ); - res.send( - ``, - ); - res.end(); - } - return; - } + const app = express(); + app.use(cookieParser()); + app.use(bodyParser.urlencoded({ extended: true })); + app.all("*", async (req, res) => { + if (!browser.wp.initialized) { + if (req.query?.domain) { + await browser.wp.init(new URL(req.query.domain).toString(), { + useFetchForRequests: true, + }); + await login(browser, "admin", "password"); + res.status(302); + res.setHeader("location", options.initialUrl); + res.end(); + } else { + res.setHeader("content-type", "text/html"); + res.send( + `` + ); + res.end(); + } + return; + } - if ( req.path.endsWith( '.php' ) || req.path.endsWith( '/' ) ) { - const parsedUrl = new URL( req.url, browser.wp.ABSOLUTE_URL ); - const pathToUse = parsedUrl.pathname.replace( '/preload/wordpress', '' ); - const wpResponse = await browser.request( { - path: pathToUse, - method: req.method, - headers: req.headers, - _GET: parsedUrl.search, - _POST: req.body, - } ); - for ( const [ key, values ] of Object.entries( wpResponse.headers ) ) { - res.setHeader( key, values ); - } - if ( 'location' in wpResponse.headers ) { - res.status( 302 ); - res.end(); - } else { - if ( wpResponse.statusCode ) { - res.status( wpResponse.statusCode ); - } - res.send( wpResponse.body ); - } - } else { - // First, check if the requested file exists in the mounts. - for ( let { absoluteHostPath, relativeWasmPath } of options.mounts ) { - if ( relativeWasmPath.startsWith( './' ) ) { - relativeWasmPath = relativeWasmPath.slice( 1 ); - } - if ( ! relativeWasmPath.startsWith( '/' ) ) { - relativeWasmPath = '/' + relativeWasmPath; - } - if ( ! relativeWasmPath.endsWith( '/' ) ) { - relativeWasmPath = relativeWasmPath + '/'; - } - if ( req.path.startsWith( relativeWasmPath ) ) { - const filePath = path.join( absoluteHostPath, req.path.replace( relativeWasmPath, '' ) ); - if ( existsSync( filePath ) ) { - res.sendFile( filePath ); - return; - } - } - } - // If the file doesn't exist in the mounts, serve it from the filesystem. - res.sendFile( - path.join( __dirname, 'wordpress', req.path ), - ); - } - } ); + if (req.path.endsWith(".php") || req.path.endsWith("/")) { + const parsedUrl = new URL(req.url, browser.wp.ABSOLUTE_URL); + const pathToUse = parsedUrl.pathname.replace("/preload/wordpress", ""); + const wpResponse = await browser.request({ + path: pathToUse, + method: req.method, + headers: req.headers, + _GET: parsedUrl.search, + _POST: req.body, + }); + for (const [key, values] of Object.entries(wpResponse.headers)) { + res.setHeader(key, values); + } + if ("location" in wpResponse.headers) { + res.status(302); + res.end(); + } else { + if (wpResponse.statusCode) { + res.status(wpResponse.statusCode); + } + res.send(wpResponse.body); + } + } else { + // First, check if the requested file exists in the mounts. + for (let { absoluteHostPath, relativeWasmPath } of options.mounts) { + if (relativeWasmPath.startsWith("./")) { + relativeWasmPath = relativeWasmPath.slice(1); + } + if (!relativeWasmPath.startsWith("/")) { + relativeWasmPath = "/" + relativeWasmPath; + } + if (!relativeWasmPath.endsWith("/")) { + relativeWasmPath = relativeWasmPath + "/"; + } + if (req.path.startsWith(relativeWasmPath)) { + const filePath = path.join( + absoluteHostPath, + req.path.replace(relativeWasmPath, "") + ); + if (existsSync(filePath)) { + res.sendFile(filePath); + return; + } + } + } + // If the file doesn't exist in the mounts, serve it from the filesystem. + res.sendFile(path.join(__dirname, "wordpress", req.path)); + } + }); - app.listen( port, async () => { - console.log( `WordPress server is listening on port ${ port }` ); - } ); - return app; + app.listen(port, async () => { + console.log(`WordPress server is listening on port ${port}`); + }); + return app; } diff --git a/src/node/index.mjs b/src/node/index.mjs index d7d8b1b020..6b94a30bcd 100644 --- a/src/node/index.mjs +++ b/src/node/index.mjs @@ -1,5 +1,9 @@ - -export { createWordPressClient, initDatabaseFromBase64File, install, login } from './bootstrap.mjs'; -import command from './command.mjs'; +export { + createWordPressClient, + initDatabaseFromBase64File, + install, + login, +} from "./bootstrap.mjs"; +import command from "./command.mjs"; export { command }; -export { startExpressServer } from './express-server.mjs'; +export { startExpressServer } from "./express-server.mjs"; diff --git a/src/shared/messaging.mjs b/src/shared/messaging.mjs index 14ca9abea6..50e151aa97 100644 --- a/src/shared/messaging.mjs +++ b/src/shared/messaging.mjs @@ -1,40 +1,50 @@ - export const DEFAULT_REPLY_TIMEOUT = 25000; let lastMessageId = 0; -export function postMessageExpectReply( messageTarget, message, ...postMessageArgs ) { - const messageId = ++lastMessageId; - messageTarget.postMessage( - { - ...message, - messageId, - }, - ...postMessageArgs - ); - return messageId; +export function postMessageExpectReply( + messageTarget, + message, + ...postMessageArgs +) { + const messageId = ++lastMessageId; + messageTarget.postMessage( + { + ...message, + messageId, + }, + ...postMessageArgs + ); + return messageId; } -export async function awaitReply( messageTarget, messageId, timeout = DEFAULT_REPLY_TIMEOUT ) { - return new Promise((resolve, reject) => { - const responseHandler = (event) => { - if (event.data.type === 'response' && event.data.messageId === messageId) { - messageTarget.removeEventListener('message', responseHandler); - clearTimeout(failOntimeout); - resolve(event.data.result); - } - }; - const failOntimeout = setTimeout(() => { - reject(new Error('Request timed out')); - messageTarget.removeEventListener('message', responseHandler); - }, timeout); - messageTarget.addEventListener('message', responseHandler); - }); +export async function awaitReply( + messageTarget, + messageId, + timeout = DEFAULT_REPLY_TIMEOUT +) { + return new Promise((resolve, reject) => { + const responseHandler = (event) => { + if ( + event.data.type === "response" && + event.data.messageId === messageId + ) { + messageTarget.removeEventListener("message", responseHandler); + clearTimeout(failOntimeout); + resolve(event.data.result); + } + }; + const failOntimeout = setTimeout(() => { + reject(new Error("Request timed out")); + messageTarget.removeEventListener("message", responseHandler); + }, timeout); + messageTarget.addEventListener("message", responseHandler); + }); } -export function responseTo( messageId, result ) { - return { - type: 'response', - messageId, - result, - }; +export function responseTo(messageId, result) { + return { + type: "response", + messageId, + result, + }; } diff --git a/src/shared/php-wrapper.mjs b/src/shared/php-wrapper.mjs index 0a8edc1f37..35fd0ac82e 100644 --- a/src/shared/php-wrapper.mjs +++ b/src/shared/php-wrapper.mjs @@ -1,64 +1,63 @@ - -const STR = 'string'; -const NUM = 'number'; +const STR = "string"; +const NUM = "number"; export default class PHPWrapper { - _initPromise; - call; - - stdout = []; - stderr = []; - - async init( PhpBinary, args = {} ) { - if (!this._initPromise) { - this._initPromise = this._init(PhpBinary, args); - } - return this._initPromise; - } - - async _init( PhpBinary, args = {} ) { - const defaults = { - onAbort( reason ) { - console.error( 'WASM aborted: ' ); - console.error( reason ); - }, - print: ( ...chunks ) => { - this.stdout.push( ...chunks ); - }, - printErr: ( ...chunks ) => { - this.stderr.push( ...chunks ); - } - }; - - const PHPModule = Object.assign({}, defaults, args); - await new PhpBinary(PHPModule); - - this.call = PHPModule.ccall; - await this.call('pib_init', NUM, [STR], []); - return PHPModule; - } - - async run( code ) { - if ( ! this.call ) { - throw new Error( `Run init() first!` ); - } - const exitCode = this.call( 'pib_run', NUM, [ STR ], [ `?>${ code }` ] ); - const response = { - exitCode, - stdout: this.stdout.join( '\n' ), - stderr: this.stderr, - }; - this.clear(); - return response; - } - - async clear() { - if ( ! this.call ) { - throw new Error( `Run init() first!` ); - } - this.call( 'pib_refresh', NUM, [], [] ); - this.stdout = []; - this.stderr = []; - } - refresh = this.clear; + _initPromise; + call; + + stdout = []; + stderr = []; + + async init(PhpBinary, args = {}) { + if (!this._initPromise) { + this._initPromise = this._init(PhpBinary, args); + } + return this._initPromise; + } + + async _init(PhpBinary, args = {}) { + const defaults = { + onAbort(reason) { + console.error("WASM aborted: "); + console.error(reason); + }, + print: (...chunks) => { + this.stdout.push(...chunks); + }, + printErr: (...chunks) => { + this.stderr.push(...chunks); + }, + }; + + const PHPModule = Object.assign({}, defaults, args); + await new PhpBinary(PHPModule); + + this.call = PHPModule.ccall; + await this.call("pib_init", NUM, [STR], []); + return PHPModule; + } + + async run(code) { + if (!this.call) { + throw new Error(`Run init() first!`); + } + const exitCode = this.call("pib_run", NUM, [STR], [`?>${code}`]); + const response = { + exitCode, + stdout: this.stdout.join("\n"), + stderr: this.stderr, + }; + this.clear(); + return response; + } + + async clear() { + if (!this.call) { + throw new Error(`Run init() first!`); + } + this.call("pib_refresh", NUM, [], []); + this.stdout = []; + this.stderr = []; + } + refresh = this.clear; } diff --git a/src/shared/wordpress.mjs b/src/shared/wordpress.mjs index 6ccc4f784e..c7e1b47647 100644 --- a/src/shared/wordpress.mjs +++ b/src/shared/wordpress.mjs @@ -1,129 +1,128 @@ -if ( typeof XMLHttpRequest === 'undefined' ) { - // Polyfill missing node.js features - import('xmlhttprequest').then(({XMLHttpRequest}) => { - global.XMLHttpRequest = XMLHttpRequest; - }); - global.atob = function(data) { - return Buffer.from(data).toString('base64'); - } +if (typeof XMLHttpRequest === "undefined") { + // Polyfill missing node.js features + import("xmlhttprequest").then(({ XMLHttpRequest }) => { + global.XMLHttpRequest = XMLHttpRequest; + }); + global.atob = function (data) { + return Buffer.from(data).toString("base64"); + }; } - export default class WordPress { - DOCROOT = '/preload/wordpress'; - SCHEMA = 'http'; - HOSTNAME = 'localhost'; - PORT = 80; - HOST = ''; - PATHNAME = ''; - ABSOLUTE_URL = ``; - - constructor( php ) { - this.php = php; - } - - async init( urlString, options = {} ) { - this.options = { - useFetchForRequests: false, - ...options - } - const url = new URL(urlString); - this.HOSTNAME = url.hostname; - this.PORT = url.port ? url.port : url.protocol === 'https:' ? 443 : 80; - this.SCHEMA = ( url.protocol || '' ).replace( ':', '' ); - this.HOST = `${ this.HOSTNAME }:${ this.PORT }`; - this.PATHNAME = url.pathname.replace(/\/+$/, ''); - this.ABSOLUTE_URL = `${this.SCHEMA}://${this.HOSTNAME}:${this.PORT}${this.PATHNAME}`; - - await this.php.refresh(); - - const result = await this.php.run( ` $headers, 'data' => $data, 'url' => $url, 'method' => $options['type'], ) ) ); @@ -198,7 +197,7 @@ PATCH if ( false ) { // Activate the development plugin. - $file_php_path = '${ this.DOCROOT }/wp-includes/functions.php'; + $file_php_path = '${this.DOCROOT}/wp-includes/functions.php'; $file_php = file_get_contents($file_php_path); if (strpos($file_php, "start-test-snippet") !== false) { @@ -208,7 +207,7 @@ PATCH $file_php .= <<<'ADMIN' // start-test-snippet add_action('init', function() { - require_once '${ this.DOCROOT }/wp-admin/includes/plugin.php'; + require_once '${this.DOCROOT}/wp-admin/includes/plugin.php'; $plugin = 'my-plugin/my-plugin.php'; if(!is_plugin_active($plugin)) { $result = activate_plugin( $plugin, '', is_network_admin() ); @@ -230,13 +229,13 @@ ADMIN; $file_php ); } - touch("${ this.DOCROOT }/.wordpress-patched"); + touch("${this.DOCROOT}/.wordpress-patched"); } `; - } + } - _setupErrorReportingCode() { - return ` + _setupErrorReportingCode() { + return ` $stdErr = fopen('php://stderr', 'w'); $errors = []; register_shutdown_function(function() use($stdErr){ @@ -254,35 +253,37 @@ ADMIN; }); error_reporting(E_ALL); `; - } - - _setupRequestCode( { - path = '/wp-login.php', - method = 'GET', - headers, - _GET = '', - _POST = {}, - _COOKIE = {}, - _SESSION = {}, - } = {} ) { - const request = { - path, - method, - headers, - _GET, - _POST, - _COOKIE, - _SESSION, - }; - - console.log( 'Incoming request: ', request.path ); - - const https = this.ABSOLUTE_URL.startsWith( 'https://' ) ? 'on' : ''; - return ` - define('USE_FETCH_FOR_REQUESTS', ${ this.options.useFetchForRequests ? 'true' : 'false' }); - define('WP_HOME', '${ this.DOCROOT }'); + } + + _setupRequestCode({ + path = "/wp-login.php", + method = "GET", + headers, + _GET = "", + _POST = {}, + _COOKIE = {}, + _SESSION = {}, + } = {}) { + const request = { + path, + method, + headers, + _GET, + _POST, + _COOKIE, + _SESSION, + }; + + console.log("Incoming request: ", request.path); + + const https = this.ABSOLUTE_URL.startsWith("https://") ? "on" : ""; + return ` + define('USE_FETCH_FOR_REQUESTS', ${ + this.options.useFetchForRequests ? "true" : "false" + }); + define('WP_HOME', '${this.DOCROOT}'); $request = (object) json_decode( - '${ JSON.stringify( request ) }' + '${JSON.stringify(request)}' , JSON_OBJECT_AS_ARRAY ); @@ -310,7 +311,7 @@ ADMIN; fwrite($stdErr, json_encode(['session' => $_SESSION]) . "\n"); - $docroot = '${ this.DOCROOT }'; + $docroot = '${this.DOCROOT}'; $script = ltrim($request->path, '/'); @@ -319,54 +320,53 @@ ADMIN; $_SERVER['PATH'] = '/'; $_SERVER['REQUEST_URI'] = $path . ($request->_GET ?: ''); - $_SERVER['HTTP_HOST'] = '${ this.HOST }'; - $_SERVER['REMOTE_ADDR'] = '${ this.HOSTNAME }'; - $_SERVER['SERVER_NAME'] = '${ this.ABSOLUTE_URL }'; - $_SERVER['SERVER_PORT'] = ${ this.PORT }; + $_SERVER['HTTP_HOST'] = '${this.HOST}'; + $_SERVER['REMOTE_ADDR'] = '${this.HOSTNAME}'; + $_SERVER['SERVER_NAME'] = '${this.ABSOLUTE_URL}'; + $_SERVER['SERVER_PORT'] = ${this.PORT}; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = $request->method; $_SERVER['SCRIPT_FILENAME'] = $docroot . '/' . $script; $_SERVER['SCRIPT_NAME'] = $docroot . '/' . $script; $_SERVER['PHP_SELF'] = $docroot . '/' . $script; $_SERVER['DOCUMENT_ROOT'] = '/'; - $_SERVER['HTTPS'] = '${ https }'; + $_SERVER['HTTPS'] = '${https}'; chdir($docroot); `; - } - - _runWordPressCode( requestPath ) { - // Resolve the .php file the request should target. - let filePath = requestPath; - if (this.PATHNAME) { - filePath = filePath.substr( this.PATHNAME.length ); - } - - // If the path mentions a .php extension, that's our file's path. - if(filePath.includes(".php")) { - filePath = filePath.split(".php")[0] + '.php'; - } else { - // Otherwise, let's assume the file is $request_path/index.php - if ( ! filePath.endsWith( '/' ) ) { - filePath += '/'; - } - if ( ! filePath.endsWith( 'index.php' ) ) { - filePath += 'index.php'; - } - } - - return ` + } + + _runWordPressCode(requestPath) { + // Resolve the .php file the request should target. + let filePath = requestPath; + if (this.PATHNAME) { + filePath = filePath.substr(this.PATHNAME.length); + } + + // If the path mentions a .php extension, that's our file's path. + if (filePath.includes(".php")) { + filePath = filePath.split(".php")[0] + ".php"; + } else { + // Otherwise, let's assume the file is $request_path/index.php + if (!filePath.endsWith("/")) { + filePath += "/"; + } + if (!filePath.endsWith("index.php")) { + filePath += "index.php"; + } + } + + return ` // The original version of this function crashes WASM WordPress, let's define an empty one instead. function wp_new_blog_notification(...$args){} // Ensure the resolved path points to an existing file. If not, // let's fall back to index.php - $candidate_path = '${ this.DOCROOT }/' . ltrim('${ filePath }', '/'); + $candidate_path = '${this.DOCROOT}/' . ltrim('${filePath}', '/'); if ( file_exists( $candidate_path ) ) { require_once $candidate_path; } else { - require_once '${ this.DOCROOT }/index.php'; + require_once '${this.DOCROOT}/index.php'; } `; - } + } } - diff --git a/src/shared/wp-browser.mjs b/src/shared/wp-browser.mjs index 6334f512d9..b56477e798 100644 --- a/src/shared/wp-browser.mjs +++ b/src/shared/wp-browser.mjs @@ -1,47 +1,56 @@ - export default class WPBrowser { - constructor( wp, config = {} ) { - this.wp = wp; - this.cookies = {}; - this.config = { - handleRedirects: false, - maxRedirects: 4, - ...config, - }; - } + constructor(wp, config = {}) { + this.wp = wp; + this.cookies = {}; + this.config = { + handleRedirects: false, + maxRedirects: 4, + ...config, + }; + } - async request( request, redirects = 0 ) { - const response = await this.wp.request( { - ...request, - _COOKIE: this.cookies, - } ); + async request(request, redirects = 0) { + const response = await this.wp.request({ + ...request, + _COOKIE: this.cookies, + }); - if ( response.headers[ 'set-cookie' ] ) { - this.setCookies( response.headers[ 'set-cookie' ] ); - } + if (response.headers["set-cookie"]) { + this.setCookies(response.headers["set-cookie"]); + } - if ( this.config.handleRedirects && response.headers.location && redirects < this.config.maxRedirects ) { - const parsedUrl = new URL( response.headers.location[ 0 ], this.wp.ABSOLUTE_URL ); - return this.request( { - path: parsedUrl.pathname, - method: 'GET', - _GET: parsedUrl.search, - headers: {}, - }, redirects + 1 ); - } + if ( + this.config.handleRedirects && + response.headers.location && + redirects < this.config.maxRedirects + ) { + const parsedUrl = new URL( + response.headers.location[0], + this.wp.ABSOLUTE_URL + ); + return this.request( + { + path: parsedUrl.pathname, + method: "GET", + _GET: parsedUrl.search, + headers: {}, + }, + redirects + 1 + ); + } - return response; - } + return response; + } - setCookies( cookies ) { - for ( const cookie of cookies ) { - try { - const value = cookie.split( '=' )[ 1 ].split( ';' )[ 0 ]; - const name = cookie.split( '=' )[ 0 ]; - this.cookies[ name ] = value; - } catch ( e ) { - console.error( e ); - } - } - } + setCookies(cookies) { + for (const cookie of cookies) { + try { + const value = cookie.split("=")[1].split(";")[0]; + const name = cookie.split("=")[0]; + this.cookies[name] = value; + } catch (e) { + console.error(e); + } + } + } } diff --git a/src/web/app.mjs b/src/web/app.mjs index 20e308c228..6d8eb98865 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -1,19 +1,24 @@ -import { runWordPress } from './library'; -import { wasmWorkerUrl, wasmWorkerBackend, wordPressSiteUrl, serviceWorkerUrl, } from './config'; +import { runWordPress } from "./library"; +import { + wasmWorkerUrl, + wasmWorkerBackend, + wordPressSiteUrl, + serviceWorkerUrl, +} from "./config"; async function init() { - console.log("[Main] Starting WordPress...") + console.log("[Main] Starting WordPress..."); - const wasmWorker = await runWordPress({ - wasmWorkerBackend, - wasmWorkerUrl, - wordPressSiteUrl, - serviceWorkerUrl, - assignScope: true - }); + const wasmWorker = await runWordPress({ + wasmWorkerBackend, + wasmWorkerUrl, + wordPressSiteUrl, + serviceWorkerUrl, + assignScope: true, + }); - console.log("[Main] WordPress is running") + console.log("[Main] WordPress is running"); - document.querySelector('#wp').src = wasmWorker.urlFor(`/wp-login.php`); + document.querySelector("#wp").src = wasmWorker.urlFor(`/wp-login.php`); } init(); diff --git a/src/web/config.js b/src/web/config.js index 845ab2581b..37c17f208f 100644 --- a/src/web/config.js +++ b/src/web/config.js @@ -1,8 +1,10 @@ +/* eslint-disable no-undef */ + // Provided by esbuild – see build.js in the repo root. export const serviceWorkerUrl = SERVICE_WORKER_URL; export const serviceWorkerOrigin = new URL(serviceWorkerUrl).origin; export const wordPressSiteUrl = serviceWorkerOrigin; export const wasmWorkerUrl = WASM_WORKER_URL; -export const wasmWorkerOrigin = new URL(wasmWorkerUrl).origin;; +export const wasmWorkerOrigin = new URL(wasmWorkerUrl).origin; export const wasmWorkerBackend = WASM_WORKER_BACKEND; diff --git a/src/web/iframe-worker.html b/src/web/iframe-worker.html index affa0f6c30..f5b81a5f13 100644 --- a/src/web/iframe-worker.html +++ b/src/web/iframe-worker.html @@ -1,9 +1,7 @@ - - - - - + + + + - diff --git a/src/web/library.js b/src/web/library.js index bca8e14e0b..85465e1c75 100644 --- a/src/web/library.js +++ b/src/web/library.js @@ -1,182 +1,206 @@ -import { postMessageExpectReply, awaitReply, responseTo, DEFAULT_REPLY_TIMEOUT } from '../shared/messaging.mjs'; +import { + postMessageExpectReply, + awaitReply, + responseTo, + DEFAULT_REPLY_TIMEOUT, +} from "../shared/messaging.mjs"; -const sleep = ms => new Promise(resolve => setTimeout(resolve, 50)); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function runWordPress({ - wasmWorkerBackend, - wasmWorkerUrl, - wordPressSiteUrl, - serviceWorkerUrl, - assignScope=true + wasmWorkerBackend, + wasmWorkerUrl, + wordPressSiteUrl, + serviceWorkerUrl, + assignScope = true, }) { - const scope = assignScope ? Math.random().toFixed(16) : undefined; - - const wasmWorker = await createWordPressWorker({ - backend: getWorkerBackend( wasmWorkerBackend, wasmWorkerUrl ), - wordPressSiteUrl: wordPressSiteUrl, - scope - }); - await registerServiceWorker({ - url: serviceWorkerUrl, - // Forward any HTTP requests to a worker to resolve them in another process. - // This way they won't slow down the UI interactions. - onRequest: async (request) => { - return await wasmWorker.HTTPRequest(request); - }, - scope - }); - return wasmWorker; + const scope = assignScope ? Math.random().toFixed(16) : undefined; + + const wasmWorker = await createWordPressWorker({ + backend: getWorkerBackend(wasmWorkerBackend, wasmWorkerUrl), + wordPressSiteUrl, + scope, + }); + await registerServiceWorker({ + url: serviceWorkerUrl, + // Forward any HTTP requests to a worker to resolve them in another process. + // This way they won't slow down the UI interactions. + onRequest: async (request) => { + return await wasmWorker.HTTPRequest(request); + }, + scope, + }); + return wasmWorker; } // // Register the service worker and handle any HTTP WordPress requests it provides us: export async function registerServiceWorker({ url, onRequest, scope }) { - if ( ! navigator.serviceWorker ) { - alert('Service workers are not supported in this browser.'); - throw new Exception('Service workers are not supported in this browser.'); - } - await navigator.serviceWorker.register(url); - const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker`); - serviceWorkerChannel.addEventListener('message', async function onMessage(event) { - /** - * Ignore events meant for other WordPress instances to - * avoid handling the same event twice. - * - * This is important because BroadcastChannel transmits - * events to all the listeners across all browser tabs. - */ - if (scope && event.data.scope !== scope) { - return; - } - console.debug(`[Main] "${event.data.type}" message received from a service worker`); - - let result; - if (event.data.type === 'request' || event.data.type === 'httpRequest') { - result = await onRequest(event.data.request); - } else { - throw new Error(`[Main] Unexpected message received from the service-worker: "${event.data.type}"`); - } - - // The service worker expects a response when it includes a `messageId` in the message: - if (event.data.messageId) { - serviceWorkerChannel.postMessage( - responseTo( - event.data.messageId, - result - ) - ); - } - console.debug(`[Main] "${event.data.type}" message processed`, { result }); - }); - navigator.serviceWorker.startMessages(); - - // Without sleep(0), the request below always returns 404. - // @TODO: Figure out why. - await sleep(0); - - const wordPressDomain = new URL(url).origin; - const wordPressBaseUrl = scope ? `${wordPressDomain}/scope:${scope}` : wordPressDomain; - const response = await fetch(`${wordPressBaseUrl}/wp-admin/atomlib.php`); - if (!response.ok) { - // The service worker did not claim this page for some reason. Let's reload. - window.location.reload(); - } + if (!navigator.serviceWorker) { + // eslint-disable-next-line no-alert + alert("Service workers are not supported in this browser."); + throw new Error("Service workers are not supported in this browser."); + } + await navigator.serviceWorker.register(url); + const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker`); + serviceWorkerChannel.addEventListener( + "message", + async function onMessage(event) { + /** + * Ignore events meant for other WordPress instances to + * avoid handling the same event twice. + * + * This is important because BroadcastChannel transmits + * events to all the listeners across all browser tabs. + */ + if (scope && event.data.scope !== scope) { + return; + } + console.debug( + `[Main] "${event.data.type}" message received from a service worker` + ); + + let result; + if (event.data.type === "request" || event.data.type === "httpRequest") { + result = await onRequest(event.data.request); + } else { + throw new Error( + `[Main] Unexpected message received from the service-worker: "${event.data.type}"` + ); + } + + // The service worker expects a response when it includes a `messageId` in the message: + if (event.data.messageId) { + serviceWorkerChannel.postMessage( + responseTo(event.data.messageId, result) + ); + } + console.debug(`[Main] "${event.data.type}" message processed`, { + result, + }); + } + ); + navigator.serviceWorker.startMessages(); + + // Without sleep(0), the request below always returns 404. + // @TODO: Figure out why. + await sleep(0); + + const wordPressDomain = new URL(url).origin; + const wordPressBaseUrl = scope + ? `${wordPressDomain}/scope:${scope}` + : wordPressDomain; + const response = await fetch(`${wordPressBaseUrl}/wp-admin/atomlib.php`); + if (!response.ok) { + // The service worker did not claim this page for some reason. Let's reload. + window.location.reload(); + } } // // -export async function createWordPressWorker({ backend, wordPressSiteUrl, scope }) { - // Keep asking if the worker is alive until we get a response - while (true) { - try { - await backend.sendMessage({ type: 'is_alive' }, 50); - break; - } catch (e) { - // Ignore timeouts - } - await sleep(50); - } - - /** - * Scoping a WordPress instances means hosting it on a - * path starting with `/scope:`. This helps WASM workers - * avoid rendering any requests meant for other WASM workers. - * - * @see registerServiceWorker for more details - */ - if (scope) { - wordPressSiteUrl += `/scope:${scope}`; - } - - // Now that the worker is up and running, let's ask it to initialize - // WordPress: - await backend.sendMessage({ - type: 'initialize_wordpress', - siteURL: wordPressSiteUrl - }); - - return { - urlFor(path) { - return `${wordPressSiteUrl}${path}`; - }, - async HTTPRequest(request) { - return await backend.sendMessage({ - type: 'request', - request - }) - } - }; +export async function createWordPressWorker({ + backend, + wordPressSiteUrl, + scope, +}) { + // Keep asking if the worker is alive until we get a response + while (true) { + try { + await backend.sendMessage({ type: "is_alive" }, 50); + break; + } catch (e) { + // Ignore timeouts + } + await sleep(50); + } + + /** + * Scoping a WordPress instances means hosting it on a + * path starting with `/scope:`. This helps WASM workers + * avoid rendering any requests meant for other WASM workers. + * + * @see registerServiceWorker for more details + */ + if (scope) { + wordPressSiteUrl += `/scope:${scope}`; + } + + // Now that the worker is up and running, let's ask it to initialize + // WordPress: + await backend.sendMessage({ + type: "initialize_wordpress", + siteURL: wordPressSiteUrl, + }); + + return { + urlFor(path) { + return `${wordPressSiteUrl}${path}`; + }, + async HTTPRequest(request) { + return await backend.sendMessage({ + type: "request", + request, + }); + }, + }; } export function getWorkerBackend(key, url) { - const backends = { - webworker: webWorkerBackend, - shared_worker: sharedWorkerBackend, - iframe: iframeBackend, - } - const backend = backends[key]; - if (!backend) { - const availableKeys = Object.keys(backends).join(", "); - throw new Error(`Unknown worker backend: "${key}". Choices: ${availableKeys}`); - } - return backend(url); + const backends = { + webworker: webWorkerBackend, + shared_worker: sharedWorkerBackend, + iframe: iframeBackend, + }; + const backend = backends[key]; + if (!backend) { + const availableKeys = Object.keys(backends).join(", "); + throw new Error( + `Unknown worker backend: "${key}". Choices: ${availableKeys}` + ); + } + return backend(url); } export function webWorkerBackend(workerURL) { - const worker = new Worker(workerURL); - return { - sendMessage: async function( message, timeout=DEFAULT_REPLY_TIMEOUT ) { - const messageId = postMessageExpectReply(worker, message); - const response = await awaitReply(worker, messageId, timeout); - return response; - } - }; + const worker = new Worker(workerURL); + return { + async sendMessage(message, timeout = DEFAULT_REPLY_TIMEOUT) { + const messageId = postMessageExpectReply(worker, message); + const response = await awaitReply(worker, messageId, timeout); + return response; + }, + }; } export function sharedWorkerBackend(workerURL) { - const worker = new SharedWorker(workerURL); - worker.port.start(); - return { - sendMessage: async function( message, timeout=DEFAULT_REPLY_TIMEOUT ) { - const messageId = postMessageExpectReply(worker.port, message); - const response = await awaitReply(worker.port, messageId, timeout); - return response; - } - }; + const worker = new SharedWorker(workerURL); + worker.port.start(); + return { + async sendMessage(message, timeout = DEFAULT_REPLY_TIMEOUT) { + const messageId = postMessageExpectReply(worker.port, message); + const response = await awaitReply(worker.port, messageId, timeout); + return response; + }, + }; } export function iframeBackend(workerDocumentURL) { - const iframe = document.createElement('iframe'); - iframe.src = workerDocumentURL; - iframe.style.display = 'none'; - document.body.appendChild(iframe); - return { - sendMessage: async function( message, timeout=DEFAULT_REPLY_TIMEOUT ) { - const messageId = postMessageExpectReply(iframe.contentWindow, message, '*'); - const response = await awaitReply(window, messageId, timeout); - return response; - } - }; + const iframe = document.createElement("iframe"); + iframe.src = workerDocumentURL; + iframe.style.display = "none"; + document.body.appendChild(iframe); + return { + async sendMessage(message, timeout = DEFAULT_REPLY_TIMEOUT) { + const messageId = postMessageExpectReply( + iframe.contentWindow, + message, + "*" + ); + const response = await awaitReply(window, messageId, timeout); + return response; + }, + }; } // diff --git a/src/web/service-worker.js b/src/web/service-worker.js index b11e78b684..873c2565a5 100644 --- a/src/web/service-worker.js +++ b/src/web/service-worker.js @@ -1,162 +1,170 @@ -import { postMessageExpectReply, awaitReply } from '../shared/messaging.mjs'; +import { postMessageExpectReply, awaitReply } from "../shared/messaging.mjs"; -const broadcastChannel = new BroadcastChannel( `wordpress-service-worker` ); +const broadcastChannel = new BroadcastChannel(`wordpress-service-worker`); /** * Ensure the client gets claimed by this service worker right after the registration. - * + * * Only requests from the "controlled" pages are resolved via the fetch listener below. * However, simply registering the worker is not enough to make it the "controller" of * the current page. The user still has to reload the page. If they don't an iframe * pointing to /index.php will show a 404 message instead of WordPress homepage. - * + * * This activation handles saves the user reloading the page after the initial confusion. * It immediately makes this worker the controller of any client that registers it. */ self.addEventListener("activate", (event) => { - event.waitUntil(clients.claim()); + // eslint-disable-next-line no-undef + event.waitUntil(clients.claim()); }); -const urlMap = {} - /** * The main method. It captures the requests and loop them back to the main * application using the Loopback request */ -self.addEventListener('fetch', (event) => { - // @TODO A more involved hostname check - const url = new URL(event.request.url); - const isWpOrgRequest = url.hostname.includes('api.wordpress.org'); - if (isWpOrgRequest) { - console.log(`[ServiceWorker] Ignoring request: ${url.pathname}`); - } - - /** - * Detect scoped requests – their url starts with `/scope:` - * - * We need this mechanics because BroadcastChannel transmits - * events to all the listeners across all browser tabs. Scopes - * helps WASM workers ignore requests meant for other WASM workers. - */ - const isScopedRequest = url.pathname.startsWith(`/scope:`); - const scope = isScopedRequest ? url.pathname.split('/')[1].split(':')[1] : null; - - const isPHPRequest = (url.pathname.endsWith('/') && url.pathname !== '/') || url.pathname.endsWith('.php'); - if (isPHPRequest) { - event.preventDefault(); - return event.respondWith( - new Promise(async (accept) => { - console.log(`[ServiceWorker] Serving request: ${url.pathname}?${url.search}`); - console.log({ isWpOrgRequest, isPHPRequest }); - const post = await parsePost(event.request); - const requestHeaders = {}; - for (const pair of event.request.headers.entries()) { - requestHeaders[pair[0]] = pair[1]; - } - - let wpResponse; - try { - const message = { - type: 'httpRequest', - scope, - request: { - path: url.pathname + url.search, - method: event.request.method, - _POST: post, - headers: requestHeaders, - }, - }; - console.log('[ServiceWorker] Forwarding a request to the main app', { message }); - const messageId = postMessageExpectReply(broadcastChannel, message); - wpResponse = await awaitReply(broadcastChannel, messageId); - console.log('[ServiceWorker] Response received from the main app', { wpResponse }); - } catch (e) { - console.error(e); - throw e; - } - - accept(new Response( - wpResponse.body, - { - headers: wpResponse.headers, - }, - )); - }), - ); - } - - const isScopedStaticFileRequest = isScopedRequest; - if (isScopedStaticFileRequest) { - const scopedUrl = url + ''; - url.pathname = '/' + url.pathname.split('/').slice(2).join('/'); - const serverUrl = url + ''; - console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); - - event.preventDefault(); - return event.respondWith( - new Promise(async (accept) => { - const newRequest = await cloneRequest(event.request, { - url: serverUrl - }); - accept(fetch(newRequest)); - }) - ); - } - - console.log(`[ServiceWorker] Ignoring a request to ${event.request.url}`); +self.addEventListener("fetch", (event) => { + // @TODO A more involved hostname check + const url = new URL(event.request.url); + const isWpOrgRequest = url.hostname.includes("api.wordpress.org"); + if (isWpOrgRequest) { + console.log(`[ServiceWorker] Ignoring request: ${url.pathname}`); + } + + /** + * Detect scoped requests – their url starts with `/scope:` + * + * We need this mechanics because BroadcastChannel transmits + * events to all the listeners across all browser tabs. Scopes + * helps WASM workers ignore requests meant for other WASM workers. + */ + const isScopedRequest = url.pathname.startsWith(`/scope:`); + const scope = isScopedRequest + ? url.pathname.split("/")[1].split(":")[1] + : null; + + const isPHPRequest = + (url.pathname.endsWith("/") && url.pathname !== "/") || + url.pathname.endsWith(".php"); + if (isPHPRequest) { + event.preventDefault(); + return event.respondWith( + new Promise(async (accept) => { + console.log( + `[ServiceWorker] Serving request: ${url.pathname}?${url.search}` + ); + console.log({ isWpOrgRequest, isPHPRequest }); + const post = await parsePost(event.request); + const requestHeaders = {}; + for (const pair of event.request.headers.entries()) { + requestHeaders[pair[0]] = pair[1]; + } + + let wpResponse; + try { + const message = { + type: "httpRequest", + scope, + request: { + path: url.pathname + url.search, + method: event.request.method, + _POST: post, + headers: requestHeaders, + }, + }; + console.log("[ServiceWorker] Forwarding a request to the main app", { + message, + }); + const messageId = postMessageExpectReply(broadcastChannel, message); + wpResponse = await awaitReply(broadcastChannel, messageId); + console.log("[ServiceWorker] Response received from the main app", { + wpResponse, + }); + } catch (e) { + console.error(e); + throw e; + } + + accept( + new Response(wpResponse.body, { + headers: wpResponse.headers, + }) + ); + }) + ); + } + + const isScopedStaticFileRequest = isScopedRequest; + if (isScopedStaticFileRequest) { + const scopedUrl = url + ""; + url.pathname = "/" + url.pathname.split("/").slice(2).join("/"); + const serverUrl = url + ""; + console.log( + `[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}` + ); + + event.preventDefault(); + return event.respondWith( + new Promise(async (accept) => { + const newRequest = await cloneRequest(event.request, { + url: serverUrl, + }); + accept(fetch(newRequest)); + }) + ); + } + + console.log(`[ServiceWorker] Ignoring a request to ${event.request.url}`); }); /** * Copy a request with custom overrides. - * + * * This function is only needed because Request properties * are read-only. The only way to change e.g. a URL is to * create an entirely new request: - * + * * https://developer.mozilla.org/en-US/docs/Web/API/Request - * - * @param {Request} request - * @param {Object} overrides - * @returns Request + * + * @param {Request} request + * @param {Object} overrides + * @return {Request} The new request. */ async function cloneRequest(request, overrides) { - const body = - ['GET', 'HEAD'].includes(request.method) - || 'body' in overrides - ? undefined - : await r.blob() - ; - return new Request(overrides.url || request.url, { - body, - method: request.method, - headers: request.headers, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - mode: request.mode, - credentials: request.credentials, - cache: request.cache, - redirect: request.redirect, - integrity: request.integrity, - ...overrides - }); + const body = + ["GET", "HEAD"].includes(request.method) || "body" in overrides + ? undefined + : await request.blob(); + return new Request(overrides.url || request.url, { + body, + method: request.method, + headers: request.headers, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + integrity: request.integrity, + ...overrides, + }); } -async function parsePost( request ) { - if ( request.method !== 'POST' ) { - return undefined; - } - // Try to parse the body as form data - try { - const formData = await request.clone().formData(); - const post = {}; +async function parsePost(request) { + if (request.method !== "POST") { + return undefined; + } + // Try to parse the body as form data + try { + const formData = await request.clone().formData(); + const post = {}; - for ( const key of formData.keys() ) { - post[ key ] = formData.get( key ); - } + for (const key of formData.keys()) { + post[key] = formData.get(key); + } - return post; - } catch ( e ) { } + return post; + } catch (e) {} - // Try to parse the body as JSON - return await request.clone().json(); + // Try to parse the body as JSON + return await request.clone().json(); } diff --git a/src/web/wasm-worker.js b/src/web/wasm-worker.js index 8ff821742c..dff0324ff5 100644 --- a/src/web/wasm-worker.js +++ b/src/web/wasm-worker.js @@ -1,163 +1,166 @@ /* eslint-disable no-inner-declarations */ -import PHPWrapper from '../shared/php-wrapper.mjs'; -import WordPress from '../shared/wordpress.mjs'; -import WPBrowser from '../shared/wp-browser.mjs'; -import { responseTo } from '../shared/messaging.mjs'; +import PHPWrapper from "../shared/php-wrapper.mjs"; +import WordPress from "../shared/wordpress.mjs"; +import WPBrowser from "../shared/wp-browser.mjs"; +import { responseTo } from "../shared/messaging.mjs"; -console.log( '[WASM Worker] Spawned' ); +console.log("[WASM Worker] Spawned"); // Infer the environment -const IS_IFRAME = typeof window !== 'undefined'; -const IS_SHARED_WORKER = typeof SharedWorkerGlobalScope !== 'undefined' && self instanceof SharedWorkerGlobalScope; -const IS_WEBWORKER = ! IS_SHARED_WORKER && typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; - -console.log( '[WASM Worker] Environment', { - IS_IFRAME, - IS_WEBWORKER, - IS_SHARED_WORKER, -} ); +const IS_IFRAME = typeof window !== "undefined"; +/* eslint-disable no-undef */ +const IS_SHARED_WORKER = + typeof SharedWorkerGlobalScope !== "undefined" && + self instanceof SharedWorkerGlobalScope; +const IS_WEBWORKER = + !IS_SHARED_WORKER && + typeof WorkerGlobalScope !== "undefined" && + self instanceof WorkerGlobalScope; +/* eslint-enable no-undef */ + +console.log("[WASM Worker] Environment", { + IS_IFRAME, + IS_WEBWORKER, + IS_SHARED_WORKER, +}); // Define polyfills if (IS_IFRAME) { - // importScripts is synchronous in a web worker. - // Let's make it async in an iframe so we can at await it before moving forward. - window.importScripts = async function (...urls) { - return Promise.all( - urls.map(url => { - const script = document.createElement('script'); - script.src = url; - script.async = false; - document.body.appendChild(script); - return new Promise(resolve => { - script.onload = resolve; - }); - }) - ) - }; + // importScripts is synchronous in a web worker. + // Let's make it async in an iframe so we can at await it before moving forward. + window.importScripts = async function (...urls) { + return Promise.all( + urls.map((url) => { + const script = document.createElement("script"); + script.src = url; + script.async = false; + document.body.appendChild(script); + return new Promise((resolve) => { + script.onload = resolve; + }); + }) + ); + }; } let phpLoaderScriptName; // Listen to messages if (IS_IFRAME) { - phpLoaderScriptName = '/php-web.js'; - window.addEventListener( - 'message', - ( event ) => handleMessageEvent( - event, - ( response ) => event.source.postMessage( response, '*' ), - ), - false, - ); -} else if ( IS_WEBWORKER ) { - phpLoaderScriptName = '/php-webworker.js'; - onmessage = ( event ) => { - handleMessageEvent( - event, - postMessage, - ); - }; -} else if ( IS_SHARED_WORKER ) { - phpLoaderScriptName = '/php-webworker.js'; - self.onconnect = ( e ) => { - const port = e.ports[ 0 ]; - - port.addEventListener( 'message', ( event ) => { - handleMessageEvent( - event, - ( r ) => port.postMessage( r ), - ); - } ); - - port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter. - }; + phpLoaderScriptName = "/php-web.js"; + window.addEventListener( + "message", + (event) => + handleMessageEvent(event, (response) => + event.source.postMessage(response, "*") + ), + false + ); +} else if (IS_WEBWORKER) { + phpLoaderScriptName = "/php-webworker.js"; + onmessage = (event) => { + handleMessageEvent(event, postMessage); + }; +} else if (IS_SHARED_WORKER) { + phpLoaderScriptName = "/php-webworker.js"; + self.onconnect = (e) => { + const port = e.ports[0]; + + port.addEventListener("message", (event) => { + handleMessageEvent(event, (r) => port.postMessage(r)); + }); + + port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter. + }; } // Actual worker logic below: // We're in a worker right now, and we're receiving the incoming // communication from the main window via `postMessage`: -async function handleMessageEvent( event, respond ) { - console.debug( `[WASM Worker] "${ event.data.type }" event received`, event ); - - const result = await generateResponseForMessage( event.data ); - - // The main window expects a response when it includes a `messageId` in the message: - if ( event.data.messageId ) { - respond( - responseTo( - event.data.messageId, - result, - ), - ); - } - - console.debug( `[WASM Worker] "${ event.data.type }" event processed` ); +async function handleMessageEvent(event, respond) { + console.debug(`[WASM Worker] "${event.data.type}" event received`, event); + + const result = await generateResponseForMessage(event.data); + + // The main window expects a response when it includes a `messageId` in the message: + if (event.data.messageId) { + respond(responseTo(event.data.messageId, result)); + } + + console.debug(`[WASM Worker] "${event.data.type}" event processed`); } let wpBrowser; -async function generateResponseForMessage( message ) { - if ( message.type === 'initialize_wordpress' ) { - wpBrowser = await initWPBrowser( message.siteURL ); - return true; - } - - if ( message.type === 'is_alive' ) { - return true; - } - - if ( message.type === 'run_php' ) { - return await wpBrowser.wp.php.run( message.code ); - } - - if ( message.type === 'request' || message.type === 'httpRequest' ) { - const parsedUrl = new URL( message.request.path, wpBrowser.wp.ABSOLUTE_URL ); - return await wpBrowser.request( { - ...message.request, - path: parsedUrl.pathname, - _GET: parsedUrl.search, - } ); - } - - console.debug( `[WASM Worker] "${ message.type }" event has no handler, short-circuiting` ); +async function generateResponseForMessage(message) { + if (message.type === "initialize_wordpress") { + wpBrowser = await initWPBrowser(message.siteURL); + return true; + } + + if (message.type === "is_alive") { + return true; + } + + if (message.type === "run_php") { + return await wpBrowser.wp.php.run(message.code); + } + + if (message.type === "request" || message.type === "httpRequest") { + const parsedUrl = new URL(message.request.path, wpBrowser.wp.ABSOLUTE_URL); + return await wpBrowser.request({ + ...message.request, + path: parsedUrl.pathname, + _GET: parsedUrl.search, + }); + } + + console.debug( + `[WASM Worker] "${message.type}" event has no handler, short-circuiting` + ); } -async function initWPBrowser(siteUrl) { - console.log('[WASM Worker] Before wp.init()'); - - // Initialize the PHP module - const php = new PHPWrapper(); - await importScripts(phpLoaderScriptName); - const PHPModule = await php.init(PHP); - - // Load the WordPress files - await new Promise((resolve) => { - PHPModule.monitorRunDependencies = (nbLeft) => { - if (nbLeft === 0) { - delete PHPModule.monitorRunDependencies; - resolve(); - } - } - // The name PHPModule is baked into wp.js - globalThis.PHPModule = PHPModule; - importScripts('/wp.js'); - }); - - // Create php.ini - PHPModule.FS.mkdirTree('/usr/local/etc'); - PHPModule.FS.writeFile('/usr/local/etc/php.ini', `[PHP] - - error_reporting = E_ERROR | E_PARSE - display_errors = 1 - html_errors = 1 - display_startup_errors = On - `); - - // We're ready to initialize WordPress! - const wp = new WordPress( php ); - await wp.init( siteUrl ); - - console.log('[WASM Worker] After wp.init()'); - - return new WPBrowser( wp, { handleRedirects: true } ); +async function initWPBrowser(siteUrl) { + console.log("[WASM Worker] Before wp.init()"); + + // Initialize the PHP module + const php = new PHPWrapper(); + // eslint-disable-next-line no-undef + await importScripts(phpLoaderScriptName); + // eslint-disable-next-line no-undef + const PHPModule = await php.init(PHP); + + // Load the WordPress files + await new Promise((resolve) => { + PHPModule.monitorRunDependencies = (nbLeft) => { + if (nbLeft === 0) { + delete PHPModule.monitorRunDependencies; + resolve(); + } + }; + // The name PHPModule is baked into wp.js + globalThis.PHPModule = PHPModule; + // eslint-disable-next-line no-undef + importScripts("/wp.js"); + }); + + // Create php.ini + PHPModule.FS.mkdirTree("/usr/local/etc"); + PHPModule.FS.writeFile( + "/usr/local/etc/php.ini", + `[PHP] +error_reporting = E_ERROR | E_PARSE +display_errors = 1 +html_errors = 1 +display_startup_errors = On + ` + ); + + // We're ready to initialize WordPress! + const wp = new WordPress(php); + await wp.init(siteUrl); + + console.log("[WASM Worker] After wp.init()"); + + return new WPBrowser(wp, { handleRedirects: true }); } diff --git a/src/web/wordpress.html b/src/web/wordpress.html index ffecdd46b4..e116bd45de 100644 --- a/src/web/wordpress.html +++ b/src/web/wordpress.html @@ -1,11 +1,13 @@ - - WordPress code embed! - - - - - + + WordPress code embed! + + + + + -