Nemo's Parental Misguidance

    @plant/plant

    2.5.0 • Public • Published

    Plant logo

    npm npm


    Plant is WebAPI standards based HTTP2 web server, created with modular architecture and functional design in mind. It's modular, pure and less coupled.

    Plant supports HTTP 1 and HTTP 2 protocols. But it's transport agnostic and can work right in the browser over WebSockets, WebRTC, or PostMessage.

    Features

    • ☁️ Lightweight: only 8 KiB minified and gzipped.
    • Serverless ready: works even in browser.
    • 🛡 Security oriented: uses the most strict Content Securiy Policy (CSP) by default.
    • 📐 Standards based: uses WebAPI interfaces.
    • 🛳 Transport agnostic: no HTTP or platform coupling, ship requests via everything.

    Table of Contents


    Install

    Production version:

    npm i @plant/plant
    

    Or development version:

    npm i @plant/plant@next
    

    Usage

    Plant is designed to platform independent thus it has no builtin transport. It requires modules for http, https, WebSocket or anything else to provide transport layer. In this example http is used and @plant/http2 should be installed (npm i @plant/http).

    ⚠️ Note that default CSP header value is default-src localhost; form-action localhost. This will prevent web page from loading any external resource at all. Set minimal required CSP on your own. Read about CSP on Mozilla Developer Network

    const createServer = require('@plant/http')
    const Plant = require('@plant/plant')
    
    // In development:
    const plant = new Plant()
    
    // In production:
    const plant = new Plant({
      csp: Plant.CSP.STRICT,
    })
    
    // Send text response
    plant.use(async function({res}) {
        res.body = 'Hello World'
    })
    
    // Build request handler
    createServer(plant)
    .listen(8080)

    Important notices

    • Plant doesn't work with native node streams. It understands only WebAPI streams. Use web-stream-polyfills package to wrap node.js stream. It's made for decreasing Node.js coupling.
    • Plant avoid extensions of Request and Response instances like Express do. It's using modifiable context for that. You should avoid extension. To prevent collisions it's recommended to use symbol as context entry name. Watch context extension example.

    Examples

    Context

    By default context has this properties:

    • reqRequest instance. Request from client.
    • resResponse instance. Response to client.
    • routeRoute instance. Current processed path.
    • socketSocket instance. Connection socket.
    • fetchfetch() function. Method to send request to itself.

    Cascades explanation

    Cascades are nested functions which passes context object to the deepest function. The flow and depth could be modified using or and and modifiers. Each level of cascade could modify context on it's own without touching overlaying or adjacent contexts.

    plant.use(async function({req, res, socket}, next) => {
      await next({}) // Set context empty
    })
    
    plant.use(async (ctx, next) => {
      ctx // -> {}
      await next({number: 3.14}) // Create new context with `number` property
    })
    
    plant.use(async (ctx, next) => {
      ctx // -> {number: 3.14}
      await next() // No context modification
    })

    It allows to create predictable behavior and avoid unexpected side effects. Plant itself overwrites default node.js HTTP Request and Response objects with Plant.Request and Plant.Response.

    Content Security Policy

    Plant has built-in CSP header definition mechanism which is very strict. And doesn't provide wide permissions as other servers do. It's rules based on principle everything which is not allowed is forbidden. And default CSP is local-only. So if the server will be deployed accidentally without correct CSP policy the server will not work.

    Default CSP is Plant.CSP.LOCAL which allows load resources only via HTTPS protocol from origin domain.

    There is 3 types of default CSP policy sets:

    • LOCAL
    • DEV
    • TEST
    • STRICT

    Example:

    const Plant = require('@plant/plant')
    
    // Default CSP value defined with Plant's constant.
    const plant = new Plant({
      csp: Plant.CSP.STRICT,
    })
    // Default CSP value defined as a method
    const plant = new Plant({
      csp: (proto, hostname, port, pathname) => `default-src ${hostname}:${port}`,
    })
    
    // Disable default CSP (not recommended)
    const plant = new Plant({
      csp: null
    })

    CSP.LOCAL (default)

    ⚠️ Not for production

    Plant.CSP.LOCAL policy set contains policies which allows localhost serving only.

    Policy Value
    default-src `localhost 'unsafe-eval' 'unsafe-inline'``
    form-action localhost

    CSP.DEV

    ⚠️ Not for production

    Plant.CSP.DEV variable contains the most permissive CSP value: default-src 'self'. Which allows to load plugins, open site in frames and send form data everywhere. This policy shouldn't be used in production never.

    Policy Value
    default-src 'self' 'unsafe-eval' 'unsafe-inline'
    form-action 'self'

    CSP.TEST

    ⚠️ Not for production

    Plant.CSP.TEST is used for local testing without HTTPS. It's very close to the STRICT policy but use 'self' as an allowed resource for loadable content and form data.

    Policy Value
    default-src 'none'
    connect-src 'self'
    font-src 'self'
    img-src 'self'
    manifest-src 'self'
    media-src 'self'
    script-src 'self'
    style-src 'self'
    worker-src 'self'
    form-action 'self'
    require-sri-for script style
    block-all-mixed-content +

    CSP.STRICT

    Safe for production

    Plant.CSP.STRICT is production version of policy set. It doesn't allow anything expect of current origin as a source of any kind of resources.The only acceptable protocol is HTTPS.

    Policy Value
    default-src 'none'
    connect-src https://%ORIGIN%
    font-src https://%ORIGIN%
    img-src https://%ORIGIN%
    manifest-src https://%ORIGIN%
    media-src https://%ORIGIN%
    script-src https://%ORIGIN%
    style-src https://%ORIGIN%
    worker-src https://%ORIGIN%
    form-action https://%ORIGIN%
    require-sri-for script style
    block-all-mixed-content +
    • %ORIGIN% is a hostname and port number from URL.

    API

    Plant Type

    Plant is the main configuration instrument. It's using to specify execution order, define routes and set uncaught error handler.

    Plant.Plant()

    ([options:PlantOptions]) -> Plant
    

    PlantOptions Type

    {
        handlers: Handlers[] = [],
        context: Object = {},
        csp: string|(protocol:string, hostname:string, port:string, pathname: string) -> string,
    }
    

    Plant server configuration options.

    Property Description
    handlers Array of request handlers added to cascade
    context Default context values. Empty object by default
    csp Default CSP header string or function which produce such string. It will be used only if CSP header isn't presented in response

    Plant#use()

    ([route:String], ...handlers:Handler) -> Plant
    

    This method do several things:

    1. If route specified, adds route matcher. Route like /blog/post will match /blog/post and /blog/post but not /blog/post-true or /blog/post/1. Wildcard domains requires asterisk at the end of route. So only route /blog/post/* will match /blog/post/ and /blog/post/1.
    2. If handler count greater than one it creates turn for request which allows to change Request execution direction.
    Example
    function conditionHandler({req}, next) {
        if (req.url.searchParams.has('page')) {
            return next()
        }
    }
    
    plant.use('/posts', conditionHandler, ({res}) => res.text('page param passed'))
    plant.use('/posts', ({res}) => res.text('page param not passed'))
    plant.use('/posts/*', ({res}) => res.text('internal page requested'))

    Plant#or()

    (...handlers: Handler) -> Plant
    

    Add handlers in parallel. Plant will iterate over handler until response body is set or any handler exists.

    Example
    plant.or(
        // Executed. Send nothing, so go to the next handler.
        ({req}) => {},
        // Executed. Send 'ok'.
        ({res}) => { res.body = 'ok' },
        // Not executed. Previous handler set response body.
        ({req}) => {}
    )

    Plant#and()

    (...handlers:Handle) -> Plant
    

    This method set new cascades. It's the same as call use for each handler.

    Example
    function add({i = 0, ctx}, next) {
        return next({...ctx, i: i + 1})
    }
    
    // Define cascade
    plant.and(add, add, add, ({i, res}) => res.text(i)) // i is 3

    Plant#getHandler()

    () -> (context: InitialContext) -> Promise<InitialContext, Error>
    

    This method returns request handler for http adapter:

    InitialContext Type

    {
        req: Request,
        res: Response,
        socket?: Socket,
        route?: Route,
        fetch: fetch,
        [key:string]?: *,
    }
    

    Initial context is minimal context which could be used by Plant handler to generate response. Entries like socket and route will be generated automatically inside of handler if they are not presented. Entry fetch is generating by default and will be overwritten.

    Example
    const http = require('http')
    const createRequestListener = require('@plant/http-adapter')
    const Plant = require('@Plant/plant')
    
    http.createServer(
      createRequestListener(plant.getHandler())
    )
    .listen(8080)

    Plant#fetch()

    (url: string|URL|Request|RequestOptions, options?: RequestOptions) -> Promise<Response>
    

    Send request to a server and retrieve a response.

    Example

    const plant = new Plant()
    
    plant.use(({res}) => {
      res.text('OK')
    })
    
    plant.fetch('/')
    .then(res) => {
      res.body // 'OK'
    })

    Handler Type

    This type specify cascadable function or object which has method to create such function.

    const Router = require('@plant/router')
    
    const router = new Router()
    router.get('/', ({res}) => {
      res.body = 'Hello'
    })
    
    server.use(router.handler())

    Peer Type

    {
      uri: URI
    }
    

    This type represents other side of request connection. It could be user or proxy-server. This instance could be non unique for each request if the peer has sent several requests using the same connection.

    For local TCP connections it could look like this:

    new Peer({
      uri: new URI({
        protocol: 'tcp:',
        hostname: '127.0.0.1',
        port: 12345,
      })
    })

    Request Type

    {
      url: URL,
      method: String,
      headers: Headers,
      domains: String[],
      body: ReadableStream|String|TypedArray|null,
      buffer: ArrayBuffer|null,
    }
    
    Property Description
    url Url is a WebAPI URL
    method HTTP method
    headers WebAPI Headers object
    domains Domains name separated by '.' in reverse order
    body Request body readable stream. It is null by default if body not exists (GET, HEAD, OPTIONS request).
    buffer If body has been read already this property will contain a buffer
    parent non-standard Request that caused current request to be called. For example for http2 push

    Request.Request()

    (options:RequestOptions) -> Request
    

    Creates and configure Request instance. Headers passed to request object should be in immutable mode.

    RequestOptions

    {
      method: String='GET',
      url: URL,
      headers: Object|Headers={},
      body: ReadableStream|Null=null,
      parent: Request|Null = null,
    }
    

    Request.is()

    (type:String) -> Boolean
    

    Determine if request header 'content-type' contains type. Needle type can be a mimetype('text/html') or shorthand ('json', 'html', etc.).

    This method uses type-is package.

    Request.type()

    (types:String[]) -> String|Null
    

    Check if content-type header contains one of the passed types. If so returns matching value either returns null.

    Example
    switch(req.type(['json', 'multipart'])) {
      case 'json':
        req.data = JSON.parse(req.body)
        break
      case 'multipart':
        req.data = parseMultipart(req.body)
        break
      default:
        req.data = {}
    }

    Request.accept()

    (types:String[]) -> String|Null
    

    Check if accept header contains one of the passed types. If so returns matching value otherwise returns null.

    Example
    switch(req.accept(['json', 'text'])) {
      case 'json':
        res.json({result: 3.14159})
        break
      case 'text':
        res.text('3.14159')
        break
      default:
        res.html('<html><body>3.14159</body></html>')
    }

    Request.arrayBuffer()

    () -> Promise<Uint8Array,Error>
    

    Read request body and returns it as an Uint8Array.

    Request.blob()

    () -> Promise<Blob,Error>
    

    ⚠️ Not implemented yet

    Read request body and returns it as a Blob.

    Request.formData()

    () -> Promise<FormData,Error>
    

    ⚠️ Not implemented yet

    Read request body and returns it as a FormData.

    Request.json()

    () -> Promise<*,Error>
    

    Read request body and parse it as JSON.

    Request.text()

    () -> Promise<string,Error>
    

    Read request body and returns it as a string.

    Response Type

    {
      url: URL,
      ok: Boolean,
      hasBody: Boolean,
      status: Number,
      statusText: String,
      headers: Headers,
      body: TypedArray|ReadableStream|String|Null,
    }
    
    Property Description
    url Request url
    ok True if status is in range of 200 and 299
    hasBody True if body is not null. Specify is response should be sent
    status Status code. 200 By default
    statusText HTTP status text representation. OK By default
    headers Response headers as WebAPI Headers object
    body Response body. Default is null
    redirected Specify wether response status is a redirection status

    Response.Response()

    (options:ResponseOptions) -> Request
    

    Creates and configure response options. Headers passed as WebAPI instance should have mode 'none'.

    ResponseOptions

    {
      url: URL,
      status: Number=200,
      headers: Headers|Object={},
      body: TypedArray|ReadableStream|String|Null=null,
    }
    

    Response.setStatus()

    (status:number) -> Response
    

    Set response status property.

    Example
    res.setStatus(200)
    .send('Hello')

    Response.redirect()

    (url:String) -> Response
    

    Redirect page to another url. Set empty body.

    Example
    res.redirect('../users')
    .text('Page moved')

    Response.json()

    (json:*) -> Response
    

    Send JS value as response with conversion it to JSON string. Set application/json content type.

    res.json({number: 3.14159})

    Response.text()

    (text:String) -> Response
    

    Send text as response. Set text/plain content type.

    Example
    res.text('3.14159')

    Response.html()

    (html:String) -> Response
    

    Send string as response. Set text/html content type.

    Example
    res.html('<html><body>3.14159</body></html>')

    Response.stream()

    (stream:Readable) -> Response
    

    Send Readable stream in response.

    Example
    res.headers.set('content-type', 'application/octet-stream')
    // You should implement webApiStream yourself it's not a standard method.
    // You can use web-streams-polyfill for it.
    res.stream(webApiStream(fs.createReadStream(req.path)))

    Response.send()

    (content:String|Buffer|Stream) -> Response
    

    Set any string-like value as response.

    Response.empty()

    () -> Response
    

    Set empty body.

    Response.push()

    (target:Request|Response|URL|string, context:Object) -> Response
    

    Push resource to the client using HTTP2 pushes mechanics. It's possible push already fetched resource, for example from cache or to push new request which will be sent with response itself.

    Example

    Push common JS and CSS for any underlaying pages:

    plant.use(({res}, next) => {
      res.push('/js/index.js')
      res.push('/css/style.css')
    
      return next()
    })
    
    plant.use('/users', ({res}) => {
      // Render user page somehow
    })
    
    plant.use('/photos', ({res}) => {
      // Render photos page somehow
    })

    Route Type

    {
      path: string,
      basePath: string,
      params: Object,
      captured: [{path: string, params: Object}],
    }
    

    Route type represents which part of path is handling now. It's using by nested routers. It stores parsed path in basePath and unparsed part in path properties. All extracted values are stored in params. Properties params and captured are frozen with Object.freeze.

    Property Description
    path Unparsed part of requested URL
    basePath Parsed part of requested URL
    params Params extracted from the basePath
    captured Captured components of route

    Route.capture()

    (path: string, [params: object]) -> Route
    

    Cut path from route Route#path and append it to Route#basePath. Extend Route#params with values from params. Push path-params pair to Route#captured array.

    Route.clone()

    () -> Route
    

    Clone route object

    Route.extend()

    (props: {
      path?: string
      basePath?: string,
      params?: object,
      captured?: [Capture],
    }) -> Route
    

    Override current values with the new props.

    
    ### Headers Type
    
    ```text
    {
      mode: String=Headers.MODE_NONE
    }
    
    Property Description
    mode Headers mutability mode

    Plant is using WebAPI Headers for Request and Response.

    // Request headers
    plant.use(async function({req}, next) {
      if (req.headers.has('authorization')) {
        const auth = req.headers.get('authorization')
        // Process authorization header...
      }
    
      await next()
    })
    
    // Response headers
    plant.use(async function({req, res}, next) {
      res.headers.set('content-type', 'image/png')
      res.send(webApiStream(fs.createReadStream('logo.png')))
    })

    Request Headers object has immutable mode (Headers.MODE_IMMUTABLE) and according to specification it will throw each time when you try to modify it.

    Headers.MODE_NONE

    String='none'
    

    Constant. Default Headers mode which allow any modifications.

    Headers.MODE_IMMUTABLE

    String='immutable'
    

    Constant. Headers mode which prevent headers from modifications.

    Headers.Headers()

    (headers:HeadersParam, mode:String=Headers.MODE_NONE) -> Headers
    

    Constructor accepts header values as object or entries and mode string. Request headers always immutable so Request.headers will always have MODE_IMMUTABLE mode value.

    HeadersParam Type

    Object.<String,String>|Array.<Array.<String, String>>
    
    Example
    const headers = new Headers({
      'content-type': 'text/plain',
    }, Headers.MODE_IMMUTABLE)
    // ... same as ...
    const headers = new Headers([
      ['content-type', 'text/plain'],
    ])

    Headers.raw()

    (header:String) -> String[]
    

    Nonstandard. Returns all header values as array. If header is not set returns empty array.

    Socket Type

    {
      peer: Peer,
      isEnded: Boolean = false,
      canPush: Boolean = false,
    }
    

    Socket wraps connection and allow disconnect from other side when needed. To stop request call socket.end(). This will prevent response from be sent and close connection. All overlay cascades will be executed, but response will not be sent.

    Socket.Socket()

    (options:{
      peer: Peer,
      onEnd?:() -> void,
      onPush?(response: Response) -> Promise<void, Error>,
    }) -> Socket
    

    Constructor has onEnd option which is a function called when connection ended and onPush option which is push handler, if it is specified then Socket#canPush will be set to true.

    Socket.canPush

    Boolean
    

    Determine wether socket allows to push responses.

    Socket.isEnded

    Boolean
    

    Property specifies whether socket is ended. Using to prevent response from sending and cascade from propagation.

    Socket.peer

    Peer
    

    Peer associated with the socket. Presented as Peer class instance.

    Socket.end()

    () -> void
    

    End connection. Call onEnd function passed into constructor.

    Socket.destroy()

    () -> void
    

    ⚠️ It should not be called in handlers. This method is for low level request handlers only.

    Destroy connection and remove events listeners.

    Socket.push()

    (response: Response) -> Promise<void,Error>
    

    Push response to the client. If it's supported.

    URI Type

    URI is an object that represents URI in plant. While URL requires protocols to be registered by IANA, WebAPI URL wouldn't parse strings with custom scheme like tcp://127.0.0.1:12345/ (127.0.0.1:12345 became a part of pathname). Thus we use URI, which doesn't mean to be an URL, but presents network identifier correct. Plant doesn't provide parser and URI should be generated manually.

    This is how Plant represents TCP address of the HTTP peer:

    new URI({
      protocol: 'tcp:',
      hostname: 'localhost',
      port: '12345',
      pathname: '/',
    })

    This implementation will be enhanced with parser in one of the next versions.

    fetch()

    (request:Request|String|URL|requestOptions, context:Object) -> Promise<Response>
    

    Send request to the server.

    plant.use(async ({res, socket, fetch}) => {
      if (socket.canPush) {
        await fetch('/style.css')
        .then((styleRes) => socket.push(styleRes))
      }
    
      res.body = '<html>...'
    })

    Error handling

    Async cascade model allow to capture errors with try/catch:

    async function errorHandler({req, res}, next) {
      try {
        await next() // Run all underlaying handlers
      }
      catch (error) {
        res.status(500)
    
        if (req.is('json')) {
          res.json({
            error: error.message,
          })
        }
        else {
          res.text(error.message)
        }
      }
    }

    Comparison

    Plant is mostly the same as Koa but it has its' own differences.

    Difference from Koa

    Plant is trying to be more lightweight like Connect and to have complete interface like Express. It uses async cascades like in Koa, but plant's context has other nature. Plant's context is plain object (not a special one) and it could be modified while moving through cascade but only for underlaying handlers:

    async function sendVersion({res, v}) {
      res.text(`version: ${v}`)
    }
    
    plant.use('/api/v1', async function(ctx, next) {
      ctx.v = 1
      // Update ctx
      await next(ctx)
    }, sendVersion) // This will send `version: 1`
    
    plant.use('/api/v2', async function(ctx, next) {
      ctx.v = 2
      // Update ctx
      await next(ctx)
    }, sendVersion) // This will send `version: 2`
    
    plant.use(sendVersion) // This will send `version: undefined`

    Also plant is using express-like response methods: text, html, json, send:

    plant.use(async function({req, res}) {
      res.send(req.stream)
    })

    Difference from Express

    Well middlewares are calling handlers (because it shorter). Plant is an object (not a function). Plant could not listening connection itself and has no listen method for that. Request and Response objects are not ancestors of native Node.js's http.IncomingMessage and http.ServerResponse.

    Domains instead of subdomains

    Request object has domains property instead of subdomains and has all parts of host from tld zone:

    req.domains // -> ['com', 'github', 'api'] for api.github.com

    No extension

    Plant doesn't extends Request or Response object with new methods. It's using context which be modified and extended with new behavior.

    License

    MIT © Rumkin

    Install

    npm i @plant/plant

    DownloadsWeekly Downloads

    32

    Version

    2.5.0

    License

    MIT

    Unpacked Size

    174 kB

    Total Files

    19

    Last publish

    Collaborators

    • rumkin