© Tristan Roberts 2012
troberts @ this domain
Differences | Usage | Functions | Source | Download ZIP

PHP Webserver

An embeddable web server designed for (and written in) PHP. it handles the control of the assigned port, setting common environmental variables (such as $_SERVER, $_GET, $_POST and $_COOKIE) and calling a function or method in your application to delegate the request.

The web server is able to be packaged in, and controlled from, your application. Therefore eliminating the requirement that your user have a standard web server installed and configured to use your web application. Combined with a database such as a flat-file database, a Berkley DB or SQLite, PHP Embeddable Web server can remove the need for the user to have any specialised libraries installed, except PHP (which is preinstalled in many Unix and Linux distros).

Differences From A Normal Server

Persistent Scripts

In a normal web server, your PHP script exits after each request. In this embedded web server, your script continues begins running when the server starts, and finishes the server is stopped. Therefore, extra care needs to be taken in garbage collection (recommended to store everything in methods/functions), security (making sure user's data is handled only in that request) and stability (making sure your script doesn't die when invalid values are passed, etc). In other words, make sure you use good software development practices.

Request Delegation

PHP Web server is not a file-based web server, like Apache and Lighttpd. Instead, when a request comes in it calls your designated callback function (or method) which must then delegate the request to the relevant controllers (if you're using MVC) or something similar.

Security

Although it can be run on any port, it's recommended to run PHP Webserver on ports above 1024. This is because many UNIX systems (including Mac OS X) require root user privileges to modify ports below 1024. If you do want to use a port below 1024, make sure to use "sudo php myappinit.php", which opens a major security hole. Furthermore, most uses of an embeddable web server use ports above 1024 to avoid conflicts with other services.

Simplicity and Speed

PHP Webserver is actually faster than Apache at serving PHP. The reasons: PHP (and the scripts) are already running and it has far simpler, with less modules and processes to go through. The effect of this is just that, it's very simple. Only GET, POST and HEAD requests are handled by default (but you can modify it to support others easily!) and things commonly controlled by modules (like spell checking, error pages, user banning, authentication, etc) must be handled by your script.

Differences in PHP (see Technical Usage)

Functions that deal with headers will not work by default (you'll have to write your own) as they go to the wrong buffer (a bug that is basically unfixable as its in the PHP core). The exceptions are header() and setcookie(), which have rewritten versions. They can be accessed with the server instance passed through the callback function/method. As header() is supported, writing alternative versions of all others is quite easy.

Installation and Usage

Install

  1. Copy the source below and save it as WebServer.php
  2. Move WebServer.php to your application's directory.

Usage

  1. Include WebServer.php in your application. Example: require_once "WebServer.php";
  2. Initialise the object. Example: $ws = new WebServer();
  3. Handle the requests by passing a callback function by name, or a class and method, or an instance and method. $ws->handleRequests("myFunction");
        // or
    $ws->handleRequests("MyClass", "myMethod");
        // or
    $ws->handleRequests($my_instance, "myMethod");
  4. Write the callback function/method. function myFunction($webserver) {
     var_dump($_SERVER);
    }

Running

  1. Open a terminal and navigate to your application.
  2. Run the application (Note: If the port is below 1024, sudo must be used). Example: sudo php myphpscript.php
  3. The server will continue to run until you quit it.

Technical Usage

Note: $instance refers to the instance of WebServer passed as the only parameter to the callback function.

header()

Original Mode

$instance->header($header);
Parameters
Example
$instance->header("Content-type: text/css");

Alternative Mode

$instance->header($name, $value);
Parameters
Example
$instance->header("Content-type", "text/css");

setcookie()

$instance->setcookie($name, $value[, $max_age]);

Parameters

Example

$instance->setcookie("username", "jsmith");

Source Code

<?php
    /*
     *  Copyright (c) 2010 Tristan Roberts
     *
     *  Permission is hereby granted, free of charge, to any person obtaining
     *  a copy of this software and associated documentation files (the
     *  "Software"), to deal in the Software without restriction, including
     *  without limitation the rights to use, copy, modify, merge, publish,
     *  distribute, sublicense, and/or sell copies of the Software, and to
     *  permit persons to whom the Software is furnished to do so, subject to
     *  the following conditions:
     *  
     *  The above copyright notice and this permission notice shall be included
     *  in all copies or substantial portions of the Software.
     *  
     *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
     *  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
     *  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
     *  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
     *  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
     *  TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
     *  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
     */

    
    /**
        The default address of the webserver (0.0.0.0 is localhost).
    */

    define("WS_ADDR", "0.0.0.0");
    
    /**
        The default port of the webserver (80 is normal HTTP).
    */

    define("WS_PORT", 8421);
    
    /**
        The default time limit for the server to run (0 is unlimited).
    */

    define("WS_TIME_LIMIT", 0);

    /**
        The default type of IP address (IPv4 or IPv6). The following
        values are allowed: 4 (IPv4) or 6 (IPv6).
    */

    define("WS_INET_TYPE", 4);
    
    /**
        The default maximum number of queued requests.
        @see    http://au.php.net/socket_listen
    */

    define("WS_MAX_QUEUED_REQUESTS", 15);
    
    /**
        The server tagline sent in headers (and in $_SERVER["SERVER_SOFTWARE"]).
        This should be short, and alphanumeric only (but not required). For
        example, Apache sends just "Apache".
    */

    define("WS_SERVER_TAGLINE", "WebServerPHP");

    /**
        Acts as a webserver, taking control of WS_DEFAULT_PORT and responding
        to each request. It sets the $_SERVER, $_GET, $_POST and $_COOKIE variables
        correctly for each request and then calls the $callback function or method.
        
        @author  Tristan Roberts
    */

    class WebServer {
        protected $_socket = null; /**< The web spcket for all connections. */
        
        protected $_port = null; /**< The port to bind to. */
        
        protected $_addr = null; /**< The address to bind to. */

        protected $_headers = array(); /**< An array of headers to send in the response. */
        
        /**
            Dies on a fatal error and writes the error message. The output looks
            like "[WEBSERVER_ERROR_00] [2010-12-18T15:19:21+00:00] [Unknown FATAL ERROR]".
            If you're running in daemon mode, you can tunnel output with
            "php myapp >> /var/log/myapp.log" or similar. If you want to send a non-fatal
            error or message to the terminal or log, use error_log("...", 0).
            
            @param  $number     The error message number.
            @param  $extra_info An array of extra variables to output (NULL default)
            @return NONE - Kills the script.
            @see    http://php.net/error_log
        */

        protected function fatalError($number, $extra_info = null) {
            $error = null;
            $message = "Unknown FATAL ERROR.";
            
            switch ($number) {
                case 1:
                    $message = "Unable to CREATE SOCKET.";
                break;
                case 2:
                    $message = "Unable to BIND SOCKET to " . $extra_info[1] . ":" . $extra_info[0] . ".";
                break;
                case 3:
                    $message = "Unable to LISTEN ON SOCKET.";
                break;
                case 4:
                    $message = "Unknown CALLBACK FUNCTION '" . $extra_info[0] . "'.";
                break;
            }
            
            $error  = "[WEBSERVER_ERROR_" . str_pad($number, 2, "0", STR_PAD_LEFT) . "] ";
            $error .= "[" . @date("c") . "] ";
            $error .= $message . "\n";
            
            die($error);
        }
        
        /**
            Creates the socket as a TCP stream and returns its status.
            This will set the stream to be either IPv4 or IPv6, depending
            on WS_INET_TYPE.
            
            @return TRUE on success, FALSE on error
            @see    WS_INET_TYPE, _socket
        */

        protected function createSocket() {
            $type = (WS_INET_TYPE < 5 ? AF_INET : AF_INET6);
            $this->_socket = socket_create($type, SOCK_STREAM, SOL_TCP);
            return (bool) $this->_socket;
        }
        
        /**
            Bind the port and address to the socket and set them in _port and _addr.
            
            @param  $port   The port to bind to (WS_PORT default)
            @param  $addr   The address to bind to (WS_ADDR default)
            @return TRUE on success, FALSE on error
            @see    _addr, _port, _socket, WS_PORT, WS_ADDR
        */

        protected function bindSocket($port = WS_PORT, $addr = WS_ADDR) {
            $this->_port = $port;
            $this->_addr = $addr;
            return (bool) socket_bind($this->_socket, $addr, $port);
        }
        
        /**
            Listens on the socket, queuing requests as it is only single-threaded.
            
            @param  $backlog The maximum number to queue (WS_MAX_QUEUED_REQUESTS)
            @return TRUE on success, FALSE on error.
            @see    WS_MAX_QUEUED_REQUESTS, _socket
        */

        protected function listenOnSocket($backlog = WS_MAX_QUEUED_REQUESTS) {
            return (bool) socket_listen($this->_socket, $backlog);
        }
        
        /**
            Sets the $_SERVER, $_GET, $_POST and $_COOKIE variables with their correct
            values based on the request. To get the requested URL, use $_SERVER["REQUEST_URI"].
            All headers will be set in $_SERVER as HTTP_HEADER_NAME, including user agent,
            languages and encoding. Currently, $_SERVER["REMOTE_ADDR"] is not set.
            
            @param  $headers    The dump from the request.
            @see    http://php.net/superglobals
            @see    WS_SERVER_TAGLINE, _port
        */

        protected function setEnvironment($headers) {
            $lines = explode("\n", $headers);
            $request = trim(array_shift($lines));
            $request = explode(" ", $request);
            $file = explode("?", trim($request[1]), 2);
            
            if (isset($file[1])) {
                $_SERVER["QUERY_STRING"] = $file[1];
                parse_str($file[1], $_GET);
            } else {
                $_SERVER["QUERY_STRING"] = "";
            }
            
            $_SERVER["REQUEST_URI"] = $file[0];
            $_SERVER["SERVER_PORT"] = $this->_port;
            $_SERVER["REQUEST_TIME"] = time();
            $_SERVER["REQUEST_METHOD"] = trim($request[0]);
            $_SERVER["SERVER_SOFTWARE"] = WS_SERVER_TAGLINE;
            $_SERVER["SERVER_PROTOCOL"] = trim($request[2]);
            
            foreach ($lines as $i => $line) {
                $parts = explode(":", $line, 2);
                if (isset($parts[1])) {
                    $name = strtoupper(str_replace("-", "_", trim($parts[0])));
                    if (!empty($name)) {
                        $_SERVER["HTTP_" . $name] = trim($parts[1]);
                    }
                } else {
                    parse_str($parts[0], $_POST);
                }
            }
            
            $cookies = explode(";", $_SERVER["HTTP_COOKIE"]);
            
            foreach ($cookies as $i => $cookie) {
                $cookie = explode("=", trim($cookie), 2);
                $name = trim($cookie[0]);
                $_COOKIE[$name] = trim($cookie[1]);
            }
        }
        
        /**
            Adds a header that will be sent in the response. This MUST be
            used instead of PHP's header() because header() goes straight
            to a PHP's internal buffer. A strange bug makes internally redirecting
            stuff up (and ocntinuously redirects). Instead, rework your code
            to call the (callback) function/method itself (you may need to
            change the value of $_SERVER["REQUEST_URI"]).
            
            @param  $name   The name of the header to send (or the full header).
            @param  $value  The value to send (NULL makes this work like PHP's normal header() function, default)
            @return TRUE on success, FALSE on error
            @see    http://php.net/header
            @see    _headers
        */

        public function header($name, $value = null) {
            if (is_null($value)) {
                $parts = explode(":", $name, 2);
                $name = trim($parts[0]);
                $value = trim($parts[1]);
            }
            $this->_headers[$name] = $value;
        }
        
        /**
            Sets a cookie using the Set-cookie header. This MUST be used
            instead of PHP's setcookie() as setcookie() uses PHP's header()
            function, which cannot be used (or overriden). Also, as of current
            only one (1) cookie can be sent back in each response. This bug
            should be fixed soon.
            
            @param  $name    The name of the cookie.
            @param  $value   The value of the cookie
            @param  $expires The max-age of the cookie (0 is forever, default)
            @see    http://php.net/setcookie
            @see    header()
        */

        public function setcookie($name, $value = "", $expires = 0) {
            $this->header("Set-cookie", $name . "=" . urlencode($value) . "; Max-Age: " . $expires);
        }
        
        /**
            Initializes the webserver on $addr:$port and allows the script to
            run for $time seconds. Falls back to the WS_* constants if no parameters
            are passed. If it cannot create, bind or listen on the socket, it will
            kill the PHP application with a fatal error.
            
            @param  $port   The port to use (WS_PORT default)
            @param  $addr   The address (IP or hostname) to bind to (WS_ADDR default)
            @param  $time   The time to let the server run (WS_TIME_LIMIT default)
            @see    WS_PORT, WS_ADDR, WS_TIME_LIMIT
            @see    createSocket(), bindSocket(), listenOnSocket(), fatalError()
        */

        public function __construct($port = WS_PORT, $addr = WS_ADDR, $time = WS_TIME_LIMIT) {
            //  Make sure the server runs for X seconds.
            @set_time_limit($time);
            
            if (!$this->createSocket()) {
                $this->fatalError(1);
            }
            
            if (!$this->bindSocket($port, $addr)) {
                $this->fatalError(2, array($port, $addr));
            }
            
            if (!$this->listenOnSocket()) {
                $this->fatalError(3);
            }
        }
        
        /**
            Closes the socket and quits the webserver.
            
            @return bool    TRUE on success, FALSE on error
            @see    _socket, __construct()
        */

        public function __destruct() {
            return (bool) socket_close($this->_socket);
        }
        
        /**
            Handles the requests and calls the callback function/method for each request.
            The callback function/method should take one (1) parameter: this instance of WebServer.
            To get the requested URL, look up $_SERVER["REQUEST_URI"]. The default headers
            are also sent, including Server (WS_SERVER_TAGLINE), X-Powered-By (PHP/VERSION),
            Cache-control ("max-age=600, private, must-revalidate"), Date (current date in RFC2822),
            Connection (close) and the Contennt-type (text/html; charset=UTF-8). All of these
            can be overriden by calling $instance->header(name, value) in the callback function.
            
            @param  $object The object (as a string or instance) or the function name.
            @param  $method The method name if using OOP-style usage.
            @see    header(), setEnvironment()
            @see    _socket, _headers, WS_SERVER_TAGLINE
            @see    example.php
        */

        public function handleRequests($object, $method = null) {
            $this->header("Server", WS_SERVER_TAGLINE);
            $this->header("X-Powered-By", "PHP/" . phpversion());
            $this->header("Cache-control", "max-age=600, private, must-revalidate");
            $this->header("Vary", "Accept-Encoding");
            $this->header("Date", @date("r"));
            $this->header("Connection", "close");
            $this->header("Content-type", "text/html; charset=UTF-8");
            
            while (true) {
                $spawn = socket_accept($this->_socket);
                while (false == ($input = socket_read($spawn, 1024)));
                
                ob_start();
                $this->setEnvironment($input);
                
                $status = 200;
                
                if (is_null($method)) {
                    $status = $object($this);
                } else if (is_string($object)) {
                    $instance = new $object();
                    $status = $instance->$method($this);
                } else {
                    $status = $object->$method($this);
                }
                
                if (!is_string($status)) {
                    $status = "200 OK";
                }
                
                if (isset($this->_headers["Location"])) {
                    $status = "302 Found";
                }
                
                $body = ob_get_clean();
                $this->header("Content-length", strlen($body));
                
                $headers = "HTTP/1.0 " . $status . "\n";
                
                foreach ($this->_headers as $name => $value) {
                    $headers .= $name . ": " . $value . "\n";
                }
                
                socket_write($spawn, $headers . "\n" . $body);
                socket_close($spawn);
            }
        }
    }
?>