Skip to content

Commit

Permalink
Add site server process (#19)
Browse files Browse the repository at this point in the history
* Add site server process

* Update `getWpNowPath` to support site server process

* Update webpack configuration to generate site server process bundle

* Use server process in the app

* Update `phpGetThemeDetails` to use server process

* Ensure server uses port defined by the app

* Extend logging to support forked processes

* Setup logging for site server process

* Improve process exit error message

* Update webpack main configuration to support extra entries

* Fix error case when waiting for a message response

* Rename wait for response function

* Rename message payload in site server process child

* Update inline comments in main webpack configuration

* Use handlers approach in server child process

* Kill process upon server stop

* Wait for server to stop
  • Loading branch information
fluiddot authored May 8, 2024
1 parent e6a7385 commit 2d37b41
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 32 deletions.
4 changes: 2 additions & 2 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { WebpackPlugin } from '@electron-forge/plugin-webpack';
import ForgeExternalsPlugin from '@timfish/forge-externals-plugin';
import ejs from 'ejs';
import { isErrnoException } from './src/lib/is-errno-exception';
import { mainConfig } from './webpack.main.config';
import mainConfig, { mainBaseConfig } from './webpack.main.config';
import { rendererConfig } from './webpack.renderer.config';
import type { ForgeConfig } from '@electron-forge/shared-types';

Expand Down Expand Up @@ -103,7 +103,7 @@ const config: ForgeConfig = {
port: 3456,
} ),
// This plugin bundles the externals defined in the Webpack config file.
new ForgeExternalsPlugin( { externals: Object.keys( mainConfig.externals ?? {} ) } ),
new ForgeExternalsPlugin( { externals: Object.keys( mainBaseConfig.externals ?? {} ) } ),
],
hooks: {
generateAssets: async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ export async function getThemeDetails( event: IpcMainInvokeEvent, id: string ) {
if ( ! server.details.running || ! server.server ) {
return null;
}
const themeDetails = await phpGetThemeDetails( server.server.php );
const themeDetails = await phpGetThemeDetails( server.server );

const parentWindow = BrowserWindow.fromWebContents( event.sender );
if ( themeDetails?.path && themeDetails.path !== server.details.themeDetails?.path ) {
Expand Down
18 changes: 10 additions & 8 deletions src/lib/php-get-theme-details.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import * as Sentry from '@sentry/electron/main';
import { WPNowServer } from '../../vendor/wp-now/src';
import SiteServerProcess from './site-server-process';

export async function phpGetThemeDetails(
php: WPNowServer[ 'php' ]
server: SiteServerProcess
): Promise< StartedSiteDetails[ 'themeDetails' ] > {
if ( ! server.php ) {
throw Error( 'PHP is not instantiated' );
}

let themeDetails = null;
const themeDetailsPhp = `<?php
require_once('${ php.documentRoot }/wp-load.php');
require_once('${ server.php.documentRoot }/wp-load.php');
$theme = wp_get_theme();
echo json_encode([
'name' => $theme->get('Name'),
Expand All @@ -19,11 +23,9 @@ export async function phpGetThemeDetails(
]);
`;
try {
themeDetails = (
await php.run( {
code: themeDetailsPhp,
} )
).text;
themeDetails = await server.runPhp( {
code: themeDetailsPhp,
} );
themeDetails = JSON.parse( themeDetails );
} catch ( error ) {
Sentry.captureException( error, {
Expand Down
87 changes: 87 additions & 0 deletions src/lib/site-server-process-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { PHPRunOptions } from '@php-wasm/universal';
import { startServer, type WPNowServer } from '../../vendor/wp-now/src';
import { WPNowOptions } from '../../vendor/wp-now/src/config';
import { setupLogging } from '../logging';
import type { MessageName } from './site-server-process';

type Handler = ( message: string, messageId: number, data: unknown ) => void;
type Handlers = { [ K in MessageName ]: Handler };

// Setup logging for the forked process
if ( process.env.STUDIO_APP_LOGS_PATH ) {
setupLogging( {
processId: 'site-server-process',
isForkedProcess: true,
logDir: process.env.STUDIO_APP_LOGS_PATH,
} );
}

const options = JSON.parse( process.argv[ 2 ] ) as WPNowOptions;
let server: WPNowServer;

const handlers: Handlers = {
'start-server': createHandler( start ),
'stop-server': createHandler( stop ),
'run-php': createHandler( runPhp ),
};

async function start() {
server = await startServer( options );
return {
php: {
documentRoot: server.php.documentRoot,
},
};
}

async function stop() {
await server.stopServer();
}

async function runPhp( data: unknown ) {
const request = data as PHPRunOptions;
if ( ! request ) {
throw Error( 'PHP request is not valid' );
}
const response = await server.php.run( request );
return response.text;
}

function createHandler< T >( handler: ( data: unknown ) => T ) {
return async ( message: string, messageId: number, data: unknown ) => {
try {
const response = await handler( data );
process.parentPort.postMessage( {
message,
messageId,
data: response,
} );
} catch ( error ) {
const errorObj = error as Error;
if ( ! errorObj ) {
process.parentPort.postMessage( { message, messageId, error: Error( 'Unknown Error' ) } );
return;
}
process.parentPort.postMessage( {
message,
messageId,
error: errorObj,
} );
}
};
}

process.parentPort.on( 'message', async ( { data: messagePayload } ) => {
const { message, messageId, data }: { message: MessageName; messageId: number; data: unknown } =
messagePayload;
const handler = handlers[ message ];
if ( ! handler ) {
process.parentPort.postMessage( {
message,
messageId,
error: Error( `No handler defined for message '${ message }'` ),
} );
return;
}
await handler( message, messageId, data );
} );
149 changes: 149 additions & 0 deletions src/lib/site-server-process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { app, utilityProcess, UtilityProcess } from 'electron';
import { PHPRunOptions } from '@php-wasm/universal';
import { WPNowOptions } from '../../vendor/wp-now/src/config';

// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const SITE_SERVER_PROCESS_MODULE_PATH: string;

export type MessageName = 'start-server' | 'stop-server' | 'run-php';

const DEFAULT_RESPONSE_TIMEOUT = 25000;

export default class SiteServerProcess {
lastMessageId = 0;
options: WPNowOptions;
process?: UtilityProcess;
php?: { documentRoot: string };
url: string;

constructor( options: WPNowOptions ) {
this.options = options;
this.url = options.absoluteUrl ?? '';
}

async start(): Promise< void > {
return new Promise( ( resolve, reject ) => {
const spawnListener = async () => {
const messageId = this.sendMessage( 'start-server' );
try {
const { php } = await this.waitForResponse< Pick< SiteServerProcess, 'php' > >(
'start-server',
messageId
);
this.php = php;
// Removing exit listener as we only need it upon starting
this.process?.off( 'exit', exitListener );
resolve();
} catch ( error ) {
reject( error );
}
};
const exitListener = ( code: number ) => {
if ( code !== 0 ) {
reject( new Error( `Site server process exited with code ${ code } upon starting` ) );
}
};

this.process = utilityProcess
.fork( SITE_SERVER_PROCESS_MODULE_PATH, [ JSON.stringify( this.options ) ], {
serviceName: 'studio-site-server',
env: {
...process.env,
STUDIO_SITE_SERVER_PROCESS: 'true',
STUDIO_APP_NAME: app.name,
STUDIO_APP_DATA_PATH: app.getPath( 'appData' ),
STUDIO_APP_LOGS_PATH: app.getPath( 'logs' ),
},
} )
.on( 'spawn', spawnListener )
.on( 'exit', exitListener );
} );
}

async stop() {
const message = 'stop-server';
const messageId = this.sendMessage( message );
await this.waitForResponse( message, messageId );
await this.#killProcess();
}

async runPhp( data: PHPRunOptions ): Promise< string > {
const message = 'run-php';
const messageId = this.sendMessage( message, data );
return await this.waitForResponse( message, messageId );
}

sendMessage< T >( message: MessageName, data?: T ) {
const process = this.process;
if ( ! process ) {
throw Error( 'Server process is not running' );
}

const messageId = +this.lastMessageId;
process.postMessage( { message, messageId, data } );
return messageId;
}

async waitForResponse< T = undefined >(
originalMessage: MessageName,
originalMessageId: number,
timeout = DEFAULT_RESPONSE_TIMEOUT
): Promise< T > {
const process = this.process;
if ( ! process ) {
throw Error( 'Server process is not running' );
}

return new Promise( ( resolve, reject ) => {
const handler = ( {
message,
messageId,
data,
error,
}: {
message: MessageName;
messageId: number;
data: T;
error?: Error;
} ) => {
if ( message !== originalMessage || messageId !== originalMessageId ) {
return;
}
process.removeListener( 'message', handler );
clearTimeout( timeoutId );
if ( typeof error !== 'undefined' ) {
reject( error );
return;
}
resolve( data );
};

const timeoutId = setTimeout( () => {
reject( new Error( `Request for message ${ originalMessage } timed out` ) );
process.removeListener( 'message', handler );
}, timeout );

process.addListener( 'message', handler );
} );
}

async #killProcess(): Promise< void > {
const process = this.process;
if ( ! process ) {
throw Error( 'Server process is not running' );
}

return new Promise( ( resolve, reject ) => {
process.once( 'exit', ( code ) => {
if ( code !== 0 ) {
reject( new Error( `Site server process exited with code ${ code } upon stopping` ) );
return;
}
resolve();
} );
process.kill();
} );
}
}
26 changes: 18 additions & 8 deletions src/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,22 @@ let logStream: ReturnType< typeof FileStreamRotator.getStream > | null = null;
// Intentional typo of 'erro' so all levels the same number of characters
export type LogLevel = 'info' | 'warn' | 'erro';

export function setupLogging() {
// Set the logging path to the default for the platform.
app.setAppLogsPath();

export function setupLogging( {
isForkedProcess = false,
processId = 'main',
// During development logs will be written to ~/Library/Logs/Electron/*.log because technically
// the app is still called Electron from the system's point of view (see `CFBundleDisplayName`)
// In the release build logs will be written to ~/Library/Logs/Studio/*.log
const logDir = app.getPath( 'logs' );
logDir = app.getPath( 'logs' ),
}: {
isForkedProcess?: boolean;
processId?: string;
logDir?: string;
} = {} ) {
if ( ! isForkedProcess ) {
// Set the logging path to the default for the platform.
app.setAppLogsPath();
}

logStream = FileStreamRotator.getStream( {
filename: path.join( logDir, 'studio-%DATE%' ),
Expand All @@ -32,7 +40,7 @@ export function setupLogging() {
const makeLogger =
( level: LogLevel, originalLogger: typeof console.log ) =>
( ...args: Parameters< typeof console.log > ) => {
writeLogToFile( level, 'main', ...args );
writeLogToFile( level, processId, ...args );
originalLogger( ...args );
};

Expand All @@ -41,12 +49,14 @@ export function setupLogging() {
console.error = makeLogger( 'erro', console.error.bind( console ) );

process.on( 'exit', () => {
logStream?.end( `[${ new Date().toISOString() }][info][main] App is terminating` );
logStream?.end( `[${ new Date().toISOString() }][info][${ processId }] App is terminating` );
} );

// Handle Ctrl+C (SIGINT) to gracefully close the log stream
process.on( 'SIGINT', () => {
logStream?.end( `[${ new Date().toISOString() }][info][main] App was terminated by SIGINT` );
logStream?.end(
`[${ new Date().toISOString() }][info][${ processId }] App was terminated by SIGINT`
);
process.exit();
} );

Expand Down
Loading

0 comments on commit 2d37b41

Please sign in to comment.