Giter VIP home page Giter VIP logo

web's Introduction

Web applications for the XP Framework

Build status on GitHub XP Framework Module BSD Licence Requires PHP 7.0+ Supports PHP 8.0+ Latest Stable Version

Low-level functionality for serving HTTP requests, including the xp web runner.

Example

use web\Application;

class Service extends Application {

  public function routes() {
    return [
      '/hello' => function($req, $res) {
        $res->answer(200, 'OK');
        $res->send('Hello '.$req->param('name', 'Guest'), 'text/plain');
      }
    ];
  }
}

Run it using:

$ xp -supervise web Service
@xp.web.srv.Standalone(HTTP @ peer.ServerSocket(Resource id #61 -> tcp://127.0.0.1:8080))
# ...

Supports a development webserver which is slower but allows an easy edit/save/reload development process. It uses the PHP development server in the background.

$ xp -supervise web -m develop Service
@xp.web.srv.Develop(HTTP @ `php -S 127.0.0.1:8080 -t  /home/example/devel/shorturl`)
# ...

Now open the website at http://localhost:8080/hello

Server models

The four server models (selectable via -m <model> on the command line) are:

  • async (the default since 3.0.0): A single-threaded web server. Handlers can yield control back to the server to serve other clients during lengthy operations such as file up- and downloads.
  • sequential: Same as above, but blocks until one client's HTTP request handler has finished executing before serving the next request.
  • prefork: Much like Apache, forks a given number of children to handle HTTP requests. Requires the pcntl extension.
  • develop: As mentioned above, built ontop of the PHP development wenserver. Application code is recompiled and application setup performed from scratch on every request, errors and debug output are handled by the development console.

Request and response

The web.Request class provides the following basic functionality:

use web\Request;

$request= ...

$request->method();       // The HTTP method, e.g. "GET"
$request->uri();          // The request URI, a util.URI instance

$request->headers();      // All request headers as a map
$request->header($name);  // The value of a single header

$request->cookies();      // All cookies
$request->cookie($name);  // The value of a single cookie

$request->params();       // All request parameters as a map
$request->param($name);   // The value of a single parameter

The web.Response class provides the following basic functionality:

use web\{Response, Cookie};

$response= ...

// Set status code, header(s) and cookie(s)
$response->answer($status);
$response->header($name, $value);
$response->cookie(new Cookie($name, $value));

// Sends body using a given content type
$response->send($body, $type);

// Transfers an input stream using a given content type. Uses
// chunked transfer-encoding.
yield from $response->transmit($in, $type);

// Same as above, but specifies content length before-hand
yield from $response->transmit($in, $type, $size);

Both Request and Response have a stream() method for accessing the underlying in- and output streams.

Handlers

A handler (also referred to as middleware in some frameworks) is a function which receives a request and response and uses the above functionality to handle communication.

use web\Handler;

$redirect= new class() implements Handler {

  public function handle($req, $res) {
    $req->status(302);
    $req->header('Location', 'https://example.com/');
  }
};

This library comes with web.handler.FilesFrom - a handler for serving files. It takes care of conditional requests (with If-Modified-Since) as well requests for content ranges, and makes use of the asynchronous capabilities if available, see here.

Filters

Filters wrap around handlers and can perform tasks before and after the handlers are invoked. You can use the request's pass() method to pass values - handlers can access these using value($name) / values().

use web\Filter;
use util\profiling\Timer;
use util\log\{Logging, LogCategory};

$timer= new class(Logging::all()->toConsole()) implements Filter {
  private $timer;

  public function __construct(private LogCategory $cat) {
    $this->timer= new Timer();
  }

  public function filter($request, $response, $invocation) {
    $this->timer->start();
    try {
      yield from $invocation->proceed($request, $response);
    } finally {
      $this->cat->debugf('%s: %.3f seconds', $request->uri(), $this->timer->elapsedTime());
    }
  }
}

By using yield from, you guarantee asynchronous handlers will have completely executed before the time measurement is run on in the finally block.

File uploads

File uploads are handled by the request's multipart() method. In contrast to how PHP works, file uploads are streamed and your handler starts running with the first byte transmitted!

use io\Folder;

$uploads= new Folder('...');
$handler= function($req, $res) use($uploads) {
  if ($multipart= $req->multipart()) {

    // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/100
    if ('100-continue' === $req->header('Expect')) {
      $res->hint(100, 'Continue');
    }

    // Transmit files to uploads directory asynchronously
    $files= [];
    $bytes= 0;
    foreach ($multipart->files() as $name => $file) {
      $files[]= $name;
      $bytes+= yield from $file->transmit($uploads);
    }

    // Do something with files and bytes...
  }
};

Early hints

An experimental status code with which headers can be sent to a client early along for it to be able to make optimizations, e.g. preloading scripts and stylesheets.

$handler= function($req, $res) {
  $res->header('Link', [
    '</main.css>; rel=preload; as=style',
    '</script.js>; rel=preload; as=script'
  ]);
  $res->hint(103);

  // Do some processing here to render $html
  $html= ...

  $res->answer(200, 'OK');
  $res->send($html, 'text/html; charset=utf-8');
}

See https://evertpot.com/http/103-early-hints

Internal redirects

On top of external redirects which are triggered by the 3XX status codes, requests can also be redirected internally using the dispatch() method. This has the benefit of not requiring clients to perfom an additional request.

use web\Application;

class Site extends Application {

  public function routes() {
    return [
      '/home' => function($req, $res) {
        // Home page
      },
      '/' => function($req, $res) {
        // Routes are re-evaluated as if user had called /home
        return $req->dispatch('/home');
      },
    ];
  }
}

Logging

By default, logging goes to standard output and will be visible in the console the xp web command was invoked from. It can be influenced via the command line as follows:

  • -l server.log: Writes to the file server.log, creating it if necessary
  • -l -: Writes to standard output
  • -l - -l server.log: Writes to both of the above

More fine-grained control as well as integrating with the logging library can be achieved from inside the application, see here.

Performance

Because the code for the web application is only compiled once when using production servers, we achieve lightning-fast request/response roundtrip times:

Network console screenshot

See also

This library provides for the very basic functionality. To create web frontends or REST APIs, have a look at the following libraries built ontop of this:

web's People

Contributors

johannes85 avatar thekid avatar

Stargazers

 avatar  avatar

Watchers

 avatar  avatar  avatar

Forkers

johannes85

web's Issues

Error handlers

Maybe something like https://gobuffalo.io/en/docs/errors?

app = buffalo.New(buffalo.Options{
  Env: ENV,
})

app.ErrorHandlers[422] = func(status int, err error, c buffalo.Context) error {
  res := c.Response()
  res.WriteHeader(422)
  res.Write([]byte(fmt.Sprintf("Oops!! There was an error %s", err.Error())))
  return nil
}

app.GET("/oops", MyHandler)

func MyHandler(c buffalo.Context) error {
  return c.Error(422, errors.New("Oh no!"))
}

Support "Range" header

Initial implementation

$file= ...
$contentType= ...

if ($range= $req->header('Range')) {
  $size= $file->size();
  $end= $size - 1;
  sscanf($range, 'bytes=%d-%d', $start, $end);

  $res->answer(206, 'Partial Content');
  $res->header('Accept-Ranges', 'bytes');
  $res->header('Content-Type', $contentType);
  $res->header('Content-Range', 'bytes '.$start.'-'.$end.'/'.$size);

  if ($start === $end) {
    $res->header('Content-Length', 0);
    $res->flush();
    return;
  }

  $res->header('Content-Length', $end - $start + 1);
  $file->open(File::READ);
  try {
    $file->seek($start);
    while ($start < $end && ($chunk= $file->read(min(8192, $end - $start + 1)))) {
      $res->write($chunk);
      $start+= strlen($chunk);
    }
  } finally {
    $file->close();
  }
} else {
  $res->answer(200, 'OK');
  $res->transfer($file->in(), $contentType, $file->size());
}

TODO: Support bytes=-50 for reading the last 50 bytes

Scope

  • Implement in web.handler.FilesFrom (which also handles If-Modified / 304 Not Modified already)
  • Allow this implementation to be reused from a userland handler, passing an io.File instance

See also

Startup errors verbosity

The Search application tries establishing a connection with MongoDB in its routes() method and fails, preventing startup. Without discussing whether this is the best behavior, it might be a bit hard to find out what's happening without the stack trace and caused by exceptions:

image

I think we should show stacktraces.

Authentication & Sessions

Authenticating

Consider the following usecases:

  • Simple authentication, such as Basic access authentication
  • Authentication chains, where we either authenticate using an admin token, by session, or basic authentication (e.g. for REST services)
  • Pluggable for external libraries, e.g. a to-be-created xp-forge/case library

Sessions

We need them in cases that the credentials are not passed on every request; or in case we want to store more than would fit into a cookie.

Consider the following usecases:

Uncaught exceptions from application setup in development mode

<?php

use lang\IllegalArgumentException;
use web\Application;

class Crash extends Application {

  public function routes() {
    throw new IllegalArgumentException('Setup failed');
  }
}
$ xp -supervise web -m develop Crash
# ...
$ curl -i http://localhost:8080/
HTTP/1.0 516 Unrecoverable Error
# ...
Fatal error: Uncaught lang\IllegalArgumentException: Setup failed

This does not occur with the default server, which yields a "nice" error page.

Integrate web entry point

As an idea, integrate web-main.php into the bin directory, with the following diff:

$ diff -u ../xp/web/bin/web-main.php  vendor/xp-forge/web/bin/
--- ../xp/web/bin/web-main.php  2018-10-09 15:14:08.213663300 +0200
+++ vendor/xp-forge/web/bin/web-main.php        2018-10-09 16:38:34.168704900 +0200
@@ -212,7 +212,7 @@
     'local'    => array(),
     'files'    => array()
   );
-  $parts= explode(PATH_SEPARATOR.PATH_SEPARATOR, get_include_path());
+  $parts= array('.'.PATH_SEPARATOR.'../../../..', get_include_path());

   // Check local module first
   scanpath($result, pathfiles($cwd), $cwd, $home);

Dependencies missing

Uncaught exception: Error (Class 'peer\server\Server' not found)

Need to add xp-framework/networking manually, this should be required by xp-forge/web

Weird problems with proxies and POST

After a POST request, sometimes the HTTP dialog appears in the browser instead of the actual rendering of it. No idea where this comes from.

.---------------.           .--------------.
| Apache        |---------->| XP Webserver |
| (offload SSL) |           |              |
`---------------´           `--------------´

httpdialoginappframe 1

Base paths

Usecase

$handlers= [
  '/projects' => new RestApi($db),
  '/static'   => new FilesFrom(new Path($root, 'src/main/webapp')),
  '/'         => new Frontend($db, new Path($root, 'src/main/handlebars'))
];

The file /static/style.css will be loaded from src/main/webapp/static/style.css, while IMO it would be more intuitive if the base was stripped. Same goes for the REST api endpoint which needs to know about the /projects prefix and either repeat or strip it.

See also

https://golang.org/pkg/net/http/#StripPrefix
http://httpd.apache.org/docs/2.4/mod/mod_alias.html#alias
https://www.nginx.com/resources/admin-guide/serving-static-content/

Authentication

Inside a new package web.filters.auth:

class Authentication implements Filter {

  // ...

  public function filter($request, $response, $invocation) {
    $credentials= $this->credentials->from($request);
    if ($user= $this->users->authenticate($credentials)) {
      $request->pass('user', $user);
      return $invocation->proceed($request, $response);
    }
    return $this->credentials->acquire($response);
  }
}
  • Implementations of Credentials will extract credentials from request - e.g. Basic or Digest auth methods
  • Implementations of Users will take care of verifying these credentials, e.g. against a file, LDAP, database or other source.

Annotation-based routing

<?php namespace com\example\api;

use lang\reflect\TargetInvocationException;

abstract class RestHandler implements \web\Handler {

  /** @return [:var] */
  private function matches($req, $method) {
    if (!$method->hasAnnotation('webmethod')) return null;

    $annotation= $method->getAnnotation('webmethod');
    if ($req->method() !== $annotation['verb']) return null;

    if (preg_match(
      '#'.preg_replace('/\{([^}]+)\}/', '(?<$1>[^\/]+)', $annotation['path']).'$#',
      $req->uri()->path(),
      $matches
    )) {
      unset($matches[0]);
      return $matches;
    }

    return null;
  }

  /** @return void */
  public final function handle($req, $res) {
    foreach (typeof($this)->getMethods() as $method) {
      if (null === ($matches= $this->matches($req, $method))) continue;

      $args= [];
      foreach ($method->getParameters() as $p) {
        $args[]= $matches[$p->getName()]
          ?? $req->header($p->getName())
          ?? $req->param($p->getName())
          ?? ($p->isOptional() ? $p->getDefaultValue() : null)
        ;
      }

      try {
        $result= cast($method->invoke($this, $args), Response::class);
      } catch (TargetInvocationException $e) {
        // $e->printStackTrace();
        $result= Response::error(500, $e->getCause()->getMessage());
      }

      $result->transmit($req, $res);
      return;
    }

    Response::error(404, 'Cannot route request to '.$req->uri()->path())->transmit($req, $res);
  }
}

Undefined variable $message

In this method the variable $message is undefined

public function log($request, $response, $error) {
$query= $request->uri()->query();
$uri= $request->uri()->path().($query ? '?'.$query : '');
if ($message) {
$this->cat->warn($response->status(), $request->method(), $uri, $error);
} else {
$this->cat->info($response->status(), $request->method(), $uri);
}
}

Chunked transfer encoding

The transfer() method should resort to using chunked transfer encoding when no content length is passed.

$ git diff
diff --git a/src/main/php/org/oneandone/punktestand/Proxy.class.php b/src/main/php/org/oneandone/punktestand/Proxy.class.php
index 58247da..1b1d80a 100755
--- a/src/main/php/org/oneandone/punktestand/Proxy.class.php
+++ b/src/main/php/org/oneandone/punktestand/Proxy.class.php
@@ -14,6 +14,16 @@ class Proxy implements \web\Handler {
     $attach= $this->api->attachment($path);

     $res->status($attach->status());
-    $res->transfer($attach->stream(), $attach->header('Content-Type'));
+    $res->header('Content-Type', $attach->header('Content-Type'));
+    $res->header('Transfer-Encoding', 'chunked');
+
+    $in= $attach->stream();
+    while ($in->available()) {
+      $chunk= $in->read();
+      $res->write(sprintf("%x\r\n%s\r\n\r\n", strlen($chunk), $chunk));
+    }
+
+    $res->write("0\r\n");
+    $in->close();
   }
 }
\ No newline at end of file

See https://tools.ietf.org/html/rfc2616#section-3.6.1

Simplify streaming to the response

Example of current code necessary

use text\json\{Json, StreamOutput, Types};
use io\streams\OutputStream;
use web\io\WriteChunks;

$res->answer(200, 'OK');
$res->header('Content-Type', 'application/json');
$res->header('Transfer-Encoding', 'chunked');
$res->flush();

$out= new class(new WriteChunks($res->output())) implements OutputStream {
  public function __construct($out) { $this->out= $out; }
  public function write($arg) { $this->out->write($arg); }
  public function flush() { }
  public function close() { $this->out->finish(); }
};

$stream= (new StreamOutput($out))->begin(Types::$ARRAY);
foreach ($result as $record) {
  $stream->element($record['t.name']);
}
$stream->close();

Issues to be solved

  • There is no easy way to get an io.streams.OutputStream from the response
  • Chunked transfer encoding must be initiated by the correct header, paired with the correct output (the web.io.WriteChunks class)

Logging to Console isn't working in dev mode on Linux

When I use the ConsoleAppender to write an log message to the console it only works when I disable the develop mode of the server.

It seems that it has someting to do with the stream the ConsoleAppender is writing to:
It writes to STDERR via Console::$err, when I change it to Console::$out it works.

Actions from classes

Introduce the following as web.handler.Action implementation:

class ActionClassesIn implements Actions {
  public function __construct(private $package, private $base= '/') { }
  public function from($req) {
    $action= 'Index';
    sscanf($req->uri()->path(), $this->base.'%s', $action);
    $class= '';
    foreach (explode('/', $action) as $segment) {
      $class.= ucfirst($segment);
    }
    return $this->package->loadClass($class.'Action')->newInstance($action);
  }
}
  • / => IndexAction
  • /index => IndexAction
  • /by/date => ByDateAction

Also make web.handler.Action an abstract base class, with its name method returning whatever was passed to its constructor.

Make "async" the default server model

Currently, we have the following server models:

  • serve (the default): A single-threaded web server, blocks until one client's HTTP request handler has finished executing before serving the next request.
  • async: Same as above, but handlers can yield control back to the server to serve other clients during lengthy operations such as file up- and downloads.
  • prefork: Much like Apache, forks a given number of children to handle HTTP requests. Requires the pcntl extension.
  • develop: As mentioned above, built ontop of the PHP development wenserver. Application code is recompiled and application setup performed from scratch on every request, errors and debug output are handled by the development console.

This issue suggests making async the new default for the next major release - version 3.0.0 at the time of writing.

Add SSL support

Proof of concept

Adding a second socket with ssl context options:

diff --git a/src/main/php/xp/web/srv/Standalone.class.php b/src/main/php/xp/web/srv/Standalone.class.php
index 0e5be20..3d4dccf 100755
--- a/src/main/php/xp/web/srv/Standalone.class.php
+++ b/src/main/php/xp/web/srv/Standalone.class.php
@@ -30,12 +30,27 @@ abstract class Standalone implements Server {
     $environment= new Environment($profile, $webroot, $docroot, $config, $args, $logging);
     $application= (new Source($source, $environment))->application($args);
     $application->routing();
+    $protocol= new HttpProtocol($application, $environment->logging());
+
+    $http= new ServerSocket($this->host, $this->port);
+    $this->server->listen($http, $protocol);
+
+    // TODO: Remove hardcoded port and certificates, fetch from command line arguments
+    $https= new class($this->host, 8443) extends ServerSocket {
+      public function listen($backlog= SOMAXCONN) {
+        $this->setSocketOption('ssl', 'allow_self_signed', true);
+        $this->setSocketOption('ssl', 'disable_compression', true);
+        $this->setSocketOption('ssl', 'verify_peer', false);
+        $this->setSocketOption('ssl', 'local_cert', 'localhost.crt');
+        $this->setSocketOption('ssl', 'local_pk', 'localhost.key');
+        parent::listen($backlog);
+      }
+    };
+    $this->server->listen($https, new SSL($protocol));
 
-    $socket= new ServerSocket($this->host, $this->port);
-    $this->server->listen($socket, new HttpProtocol($application, $environment->logging()));
     $this->server->init();
 
-    Console::writeLine("\e[33m@", nameof($this), '(HTTP @ ', $socket->toString(), ")\e[0m");
+    Console::writeLine("\e[33m@", nameof($this), '(HTTP @ ', $http->toString(), ")\e[0m");
     Console::writeLine("\e[1mServing ", $application, $config, "\e[0m > ", $environment->logging()->target());
     Console::writeLine("\e[36m", str_repeat('═', 72), "\e[0m");
 

...and wrapping the protocol in this class:

<?php namespace xp\web\srv;

use peer\server\ServerProtocol;
use peer\server\protocol\SocketAcceptHandler;

/**
 * HTTPS transport
 *
 * @see  https://github.com/FiloSottile/mkcert
 * @see  https://letsencrypt.org/docs/certificates-for-localhost/
 */
class SSL implements ServerProtocol, SocketAcceptHandler {
  private $underlying;

  public function __construct(ServerProtocol $underlying) {
    $this->underlying= $underlying;
  }

  /**
   * Initialize Protocol
   *
   * @return bool
   */
  public function initialize() {
    return $this->underlying->initialize();
  }

  /**
   * Handle client connect
   *
   * @param  peer.Socket $socket
   * @return bool
   */
  public function handleAccept($socket) {
    return stream_socket_enable_crypto(
      $socket->getHandle(),
      true,
      STREAM_CRYPTO_METHOD_TLS_SERVER
    );
  }

  /**
   * Handle client connect
   *
   * @param  peer.Socket $socket
   * @return void
   */
  public function handleConnect($socket) {
    $this->underlying->handleConnect($socket);
  }

  /**
   * Handle client disconnect
   *
   * @param  peer.Socket $socket
   * @return void
   */
  public function handleDisconnect($socket) {
    $this->underlying->handleDisconnect($socket);
  }

  /**
   * Handle client data
   *
   * @param  peer.Socket $socket
   * @return ?iterable
   */
  public function handleData($socket) {
    return $this->underlying->handleData($socket);
  }

  /**
   * Handle I/O error
   *
   * @param  peer.Socket $socket
   * @param  lang.XPException $e
   * @return void
   */
  public function handleError($socket, $e) {
    $this->underlying->handleError($socket, $e);
  }
}

Both include hints as to where our sockets API in xp-framework/networking is not sufficient yet.

Limitations

This only works for standalone webservers - default, async, fork, prefork - and the ext/sockets version might not be trivial. For the development webserver, we would need to implement a proxy.

Multiple location headers

X-Location:http
X-Location:/csv
X-Location:
X-Location:
X-Location:
X-Location:localhost:8080

Happens when passing instances of util.URI as header('Location', $uri).

Returning values from filters

For easier testing, if filter() returned whatever Invocation::proceed() returned (which is typically the case, most filters are implemented w/ return $invocation->proceed($request, $response);), and Invocation::proceed() returned whatever routing returns, then we could simplify the following:

// Before
private function filter(Request $request): Request {
  new BehindProxy()->filter(
    $request,
    new Response(new TestOutput()),
    new Invocation(Routing::cast(function($req, $res) use(&$passed) { $passed= $req; }))
  );
  return $passed;
}

// After
private function filter(Request $request): Request {
  return new BehindProxy()->filter(
    $request,
    new Response(new TestOutput()),
    new Invocation(Routing::cast(($req, $res) ==> $req))
  );
}

On a side note, Invocation could be more liberal in what it accepts, calling Routing::cast() internally.

Send "Content-Length: 0" for empty responses

I noticed curl hangs for this code:

function($req, $res) {
  $res->answer(302);
  $res->header('Location', ...);
}

In its verbose output, I can see the following:

* no chunk, no close, no size. Assume close to signal end

Routing release

With PR #111 and #112, we have the following options for the next release:

Both into 4.2.0

Merge both pull requests into a feature release. There are theoretical small breaks in the routing PR, e.g. with /{id} which previously matched a path containing curly braces and the characters id, and now serves as a placeholder. The deprecated web.Routing would be removed in 5.0.0, which could be released pretty much right after.

Dispatch into 4.2.0, routing into 4.3.0

Create a feature release with dispatching and defer routing until the next feature release. This would allow to separate effects of these two features should it be necessary.

Dispatch into 4.2.0, routing into 5.0.0

Create a feature release with dispatching but defer routing until the next major release. This will allow xp-forge/frontend#48 to retain support for 4.X in contrast to the next option. To keep a transition period, the deprecated web.Routing would not be removed until 6.0.0.

Both into 5.0.0

Due to the small (but existant!) BC breaks roll both as a major release. As above, the deprecated web.Routing would not be removed until 6.0.0.

Allow using filenames

$ xp web Service.class.php
Uncaught exception: Exception lang.ClassNotFoundException (
  Class "Service.class.php" could not be found
)

Make Application routable

Idea: Accept web.Application instances in Routing::cast($r) alongside the following:

  • web.Handler
  • web.Routing
  • function(web.Request, web.Response)
  • [:web.Handler|function(web.Request, web.Response)], e.g. ['/' => function($req, $res) { ... }]

Shutdown duration

When running an XP webserver in a docker container, shutdown takes quite long:

# First shell
$ docker run --rm -p 8080:8080 fixture Hello
@xp.web.srv.Serve(HTTP @ peer.ServerSocket(Resource id #63 -> tcp://0.0.0.0:8080))
Serving Hello(static)[] > web.logging.ToConsole
════════════════════════════════════════════════════════════════════════
> Server started: http://127.0.0.1:8080 in 0.009 seconds
  Sat, 17 Apr 2021 09:56:56 +0000 - PID 10; press Ctrl+C to exit

# Another shell
$ time docker stop 3cd98616888a
3cd98616888a

real    0m10.681s
user    0m0.030s
sys     0m0.091s

This is the Dockerfile:

FROM php:8.0-cli-alpine

RUN docker-php-ext-install -j$(nproc) bcmath pcntl

RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing gnu-libiconv

ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so php

RUN curl -sSL https://dl.bintray.com/xp-runners/generic/xp-run-8.3.0.sh > /usr/bin/xp-run

RUN mkdir /app

COPY Hello.class.php /app

COPY class.pth /app

COPY vendor/ /app/vendor/

WORKDIR /app

EXPOSE 8080

ENTRYPOINT ["/bin/sh", "/usr/bin/xp-run", "xp.web.Runner", "-a", "0.0.0.0:8080"]

This might be related to the XP runners and not XP web servers, but noticed it here first!

Dependants compatibility

The following libraries need to be made compatible with ^3.0 after its release:

Add Websockets support

Entry points

Both web applications and websocket listeners can be started separately, or in combination.

# Start web app, the class extends web.Application
$ xp web de.thekid.example.App -a 0.0.0.0:8080

# Start websockets listener, the class extends web.Listeners
$ xp web de.thekid.example.Listen -a 0.0.0.0:8081

# Start both on same port, class shown below
$ xp web de.thekid.example.Chat -a 0.0.0.0:8080

The Listener subclass sets up handlers for websocket endpoints. The simplemost form is a function which accepts a message and returns the response. The Chat service binds both together, so they can share the connections member:

<?php namespace de\thekid\example;

use io\redis\RedisProtocol;
use peer\SocketException;
use web\protocol\{Protocols, Http, WebSockets};
use web\{Service, Listeners};
use xp\web\ServeDocumentRootStatically;

class Chat extends Service {
  private $connections= [];

  public function serve($server, $environment) {
    $dsn= $environment->arguments()[0] ?? 'redis://localhost';

    // Subscribe to Redis queues
    $sub= new RedisProtocol($dsn);
    $sub->command('SUBSCRIBE', ':broadcast');
    $server->select($sub->socket(), function() use($sub) {
      [$type, $channel, $message]= $sub->receive();
      foreach ($this->connections[$channel] ?? [] as $index => $connection) {
        try {
          $connection->send(json_encode(['kind' => 'MESSAGE', 'value' => $message]));
        } catch (SocketException $e) {
          unset($this->connections[$channel][$index]);  // Client disconnected
        }
      }
    });

    // Publish to Redis queues from the listener
    $pub= new RedisProtocol($dsn);
    $listener= newinstance(Listeners::class, [$environment], [
      'connections' => null,
      'on' => function() use($pub, $sub) {
        return ['/chat' => function($connection, $message) use($pub, $sub) {
          $value= json_decode($message, true);
          switch ($value['kind']) {
            case 'PING': $return= ['kind' => 'PONG']; break;
            case 'MESSAGE': $return= ...; break;
            case 'SUBSCRIBE': $return= ...; break;
            default: $return= ['kind' => 'ERROR', 'value' => 'Unknown '.$value['kind']];
          }
          $connection->send(json_encode($return));
          return $return['kind'];
        }];
      }
    ]);

    $listener->connections= &$this->connections;
    $logging= $environment->logging();
    return new Protocols(
      new Http(new ServeDocumentRootStatically($environment), $logging),
      ['websocket' => new WebSockets($listener, $logging)]
    );
  }
}

⚠️ Websocket listeners cannot be used with the development webserver, as they require the connection to be kept open!

See also

https://tools.ietf.org/html/rfc6455

Date header

http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.18

Origin servers MUST include a Date header field in all responses, except in these cases: 

      1. If the response status code is 100 (Continue) or 101 (Switching
         Protocols), the response MAY include a Date header field, at
         the server's option.
      2. If the response status code conveys a server error, e.g. 500
         (Internal Server Error) or 503 (Service Unavailable), and it is
         inconvenient or impossible to generate a valid Date.
      3. If the server does not have a clock that can provide a
         reasonable approximation of the current time, its responses
         MUST NOT include a Date header field. In this case, the rules
         in section 14.18.1 MUST be followed.

Warnings with PHP 8.1

  • parse_str(): Passing null to parameter 1 ($string) of type string is deprecated" in ::parse_str() (Request.class.php, line 134, occured once)
  • strpos(): Passing null to parameter 1 ($haystack) of type string is deprecated" in ::strpos() (Request.class.php, line 157, occured once)
  • strcspn(): Passing null to parameter 1 ($string) of type string is deprecated" in ::strcspn() (Headers.class.php, line 74, occured once)
  • substr(): Passing null to parameter 1 ($string) of type string is deprecated" in ::substr() (Headers.class.php, line 75, occured once)
  • strlen(): Passing null to parameter 1 ($string) of type string is deprecated" in ::strlen() (TestInput.class.php, line 32, occured once)
  • sscanf(): Passing null to parameter 1 ($string) of type string is deprecated" in ::sscanf() (ReadChunks.class.php, line 26, occured once)
  • is_dir(): Passing null to parameter 1 ($filename) of type string is deprecated" in ::is_dir() (Stream.class.php, line 89, occured once)
  • addcslashes(): Passing null to parameter 1 ($string) of type string is deprecated" in ::addcslashes() (Stream.class.php, line 88, occured once)

curl: (52) Empty reply from server

If neither flush() nor transfer(), stream() or send() is called on the response, an empty response is created.

function($req, $res) {
  $res->answer(200);
}

Call to undefined method xp::stringOf())

$ xp web -r static -
Uncaught exception: Error (Call to undefined method xp::stringOf())
  at <source> [line 395 of ...\Socket.class.php]
  at <main>('-r', 'static', '-') [line 0 of xp.web.Runner]
  at peer.Socket->toString() [line 34 of Standalone.class.php]
  at xp.web.srv.Standalone->serve('-', 'dev', io.Path{}, io.Path{}, array[0], array[0], '-') [line 123 of Runner.class.php]
  at xp.web.Runner::main(array[3]) [line 381 of class-main.php]

4.0 compatibility

$ grep '"xp-forge/web"' */composer.json
frontend/composer.json:    "xp-forge/web": "^3.0 | ^2.9",
lambda-ws/composer.json:    "xp-forge/web": "^3.0",
rest-api/composer.json:    "xp-forge/web": "^3.0 | ^2.0 | ^1.0",
sessions/composer.json:    "xp-forge/web": "^3.0 | ^2.0 | ^1.0",
web-auth/composer.json:    "xp-forge/web": "^3.0 | ^2.0 | ^1.0",
web/composer.json:  "name" : "xp-forge/web",

The following libraries' dependencies need to be updated when we release 4.0

  • xp-forge/frontend
  • xp-forge/lambda-ws
  • xp-forge/rest-api
  • xp-forge/sessions
  • xp-forge/web-auth

Error message design

Currently, the default error pages look like this:

image

These could use a bit more "freshness"

Canonicalize URL before matching

GET //home will not invoke the handler in this case:

class Test extends \web\Application {
  public function routes() {
    return ['/home' => function($req, $res) {
      $res->answer(200);
      $res->send("<h1>You're at home!</h1>", 'text/html');
    }];
  }
}

File uploads

Last prerequisite for a 1.0:

  • $req->files()?
  • (new FileUpload($req))->files();

Application initialization

<?php namespace de\thekid\dialog;

use de\thekid\dialog\storage\Storage;
use io\Path;
use util\cmd\Console;
use web\Application;

class App extends Application {
  private $storage;

  public function __construct($env) {
    parent::__construct($env);

    // TODO: Add this to the xp-forge/web API, ensuring it's only executd
    // once even with `-m develop`.
    $this->initialize();
  }

  public function initialize() {
    $this->storage= new Storage(new Path($this->environment->arguments()[0] ?? '.'));
    Console::writeLine("\e[1m══ Welcome to Dialog ═══════════════════════════════════════════════════\e[0m");
    Console::writeLine('Storage @ ', $this->storage->path(), "\n");

    // Perform any necessary migrations
    $schemas= new Path($this->environment->webroot(), 'src/main/sql');
    foreach ($this->storage->migrations($schemas) as $migration) {
      foreach ($migration->perform() as $result) {
        Console::writeLine("\e[33;1m>\e[0m ", $result);
      }
    }

    Console::writeLine("> Initialization complete\n");
  }

  public function routes() {
    // Shortened for brevity
  }
}

Can we get this done?

  • Easy for standalone implementation
  • Might be done with calling initialize inside development webserver wrapper process, then serializing to environment variable
  • Apache / FCGI / … ??? - use tempfiles? Hrm.

Cache control

http-cache verifies that the page and all its resources follow a good, sustainable caching strategy.

The right caching strategy can help improve site performance through:

  • Shorter load times
  • Reduced bandwidth
  • Reduced server costs
  • Having predictable behavior across browsers

image

See https://webhint.io/docs/user-guide/hints/hint-http-cache/

Limit HTTP headers, respond with 431 "Entity Too Large"

This would prevent out of memory scenarios. Defaults vary, around 8K - 16K, see https://stackoverflow.com/questions/686217/maximum-on-http-header-values - however, 8K is easily reached by some of the newer JWT-cookies.

Motivation

Uncapped HTTP header size keeps the server exposed to attacks and can bring down its capacity to serve organic traffic.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.