~benaiah/fennel-openresty

e98988b1625bc1cd3a3ffc9e0a089e390bd1d996 — Benaiah Mischenko 2 years ago fe00026
docker, docker-compose, postgres, migrations, etc
M Dockerfile => Dockerfile +42 -1
@@ 6,4 6,45 @@ WORKDIR /app

COPY . /app

EXPOSE 80
\ No newline at end of file
EXPOSE 80

RUN echo "include /app/nginx/conf/nginx.conf;" > /etc/nginx/nginx.conf \
    && echo "include /app/nginx/conf/nginx.conf;" > /usr/local/openresty/nginx/conf/nginx.conf

RUN DEBIAN_FRONTEND=noninteractive apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
       apt-utils make openresty-opm rlwrap wget

RUN printf '#!/usr/bin/env bash\nexec rlwrap /app/modules/fennel/fennel "$@"\n' | tee /usr/local/bin/fennel

# RUN mkdir /var/log/postgres \
#     && echo "deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list \
#     && wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | DEBIAN_FRONTEND=noninteractive apt-key add - \
#     && DEBIAN_FRONTEND=noninteractive apt-get update \
#     && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql-11

ENV NVM_DIR /usr/local/nvm
ENV NODE_VERSION 10.15.1
RUN mkdir $NVM_DIR \
    && curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.34.0/install.sh | bash \
    && . $NVM_DIR/nvm.sh \
    && nvm install $NODE_VERSION \
    && nvm alias default $NODE_VERSION \
    && nvm use default
ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | DEBIAN_FRONTEND=noninteractive apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \
    && DEBIAN_FRONTEND=noninteractive apt-get remove -y cmdtest \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends apt-transport-https \
    && DEBIAN_FRONTEND=noninteractive apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends yarn

RUN opm get leafo/pgmoon

RUN ln -sf /usr/local/openresty/luajit/bin/luajit /usr/local/bin/lua \
    && make
    
RUN ln -sf /dev/stdout /app/nginx/logs/access.log \
    && ln -sf /dev/stderr /app/nginx/logs/error.log
\ No newline at end of file

M app/makefile => app/makefile +8 -6
@@ 1,5 1,5 @@
.PHONY: all
all: dist/main.js
all: dist/app.js dist/app.html

.PHONY: clean
clean:


@@ 7,9 7,11 @@ clean:
	rm -f src/*.lua
	rm -rf node_modules

dist/main.js: src/react-helpers.lua src/app.lua src/index.js
dist/app.html: src/app.html.fnl
	modules/fennel/fennel $< > $@
dist/app.js: src/react-helpers.lua src/app.lua src/index.js src/set-function-name.js
	yarn && yarn run build
src/app.lua: src/app.fnl
	modules/fennel/fennel --compile $^ > $@
src/react-helpers.lua: src/react-helpers.fnl
	modules/fennel/fennel --compile $^ > $@
src/app.lua: src/app.fnl src/react-macros.fnl
	modules/fennel/fennel --compile $< > $@
src/react-helpers.lua: src/react-helpers.fnl src/react-macros.fnl
	modules/fennel/fennel --compile $< > $@

M app/package.json => app/package.json +1 -1
@@ 1,7 1,7 @@
{
  "name": "fennel-openresty",
  "version": "0.0.1",
  "main": "dist/main.js",
  "main": "dist/app.js",
  "author": "Benaiah Mischenko",
  "license": "AGPL",
  "scripts": {

M app/src/app.fnl => app/src/app.fnl +72 -25
@@ 1,35 1,73 @@
(require-macros :src.react-macros)

(local React (require "react"))
(local ReactDOM (require "react-dom"))
(local <> React.Fragment)

(local {:use-state use-state
        :use-effect use-effect
        :use-layout-effect use-layout-effect
        :use-ref use-ref
        :use-previous use-previous
        :create-context create-context
        :use-context use-context
        :get-children-as-array get-children-as-array}
       (require "./react-helpers.lua"))

(local console {:log (fn [...] (: js.global.console :log ...))})

(component! LogLine [{:children message}]
  (c! :div {:className :log-message} message))
  (c! :pre {:className :log-message} message))

(component! Log [{:children maybe-children}]
  (let [children (get-children-as-array maybe-children)]
    (: children :map (fn [_ child i] (c! LogLine {:key i} child)))))
  (let [children (get-children-as-array maybe-children)
        container-ref (use-ref nil)]
    ;; scroll to bottom on change
    (use-effect
     (fn []
       (when (and container-ref.current (not (= container-ref.current js.null))
                  container-ref.current.lastChild (not (= container-ref.current.lastChild js.null)))
         (: container-ref.current.lastChild :scrollIntoView (js! {}))))
     (js! [children.length]))
    (c! :div {:style {:color :#EEEEEE
                      :backgroundColor :#222222
                      :overflowY :scroll
                      :position :fixed
                      :left :0px
                      :right :0px
                      :top :100px
                      :height "calc(100vh - 200px)"}
              :ref container-ref}
        (: children :map (fn [_ child i] (c! LogLine {:key i} child))))))

(component! WebSocketDebugForm
  [{:connect connect :disconnect disconnect :on-message on-message}]
  (let [(current-message set-current-message) (use-state "")]
  (let [input-element (use-ref nil)
        (current-message set-current-message) (use-state "")
        maybe-prev-message (use-previous current-message)
        prev-message (if maybe-prev-message maybe-prev-message "")]
    (use-effect
     (fn [] (when input-element.current (: input-element.current :focus)) nil))
    (c! [:div {}
         [:form {:onSubmit (fn []
                             (on-message current-message)
                             (set-current-message ""))}
         [:form {:onSubmit
                 (fn [_ e]
                   (: e :preventDefault)
                   (on-message current-message)
                   (set-current-message ""))}
          [:button {:type :button :onClick connect} :Connect]
          [:button {:type :button :onClick disconnect} :Disconnect]
          [:input {:id :text
                   :type :text
                   :value current-message
                   :onChange (fn [_ e] (set-current-message e.target.value))}]
                   :ref input-element
                   :onChange
                   (fn []
                     ;; (print (.. "Changing current-message '"
                     ;;            (if current-message current-message "")
                     ;;            "' to '"
                     ;;            input-element.current.value
                     ;;            "'."))
                     (set-current-message input-element.current.value))
                   }]
          [:button {:type :submit} :Send]]])))

(fn create-websocket


@@ 48,32 86,41 @@
(local ws-url "ws://localhost:8090/wss")
(component! App []
  (let [(log-messages set-log-messages) (use-state (js! []))
        (pending-messages set-pending-messages) (use-state (js! []))
        (ws set-ws) (use-state nil)
        log (fn [message]
              (: js.global.console :log message)
              (console.log message)
              (set-log-messages
               (fn [_ prev-messages] (: prev-messages :concat message))))
        ws-on-open (fn [] (log "connected"))
        ws-on-error (fn [_ err] (log err))
        ws-on-error (fn [] (log "websocket error!"))
        ws-on-message (fn [_ e] (log (.. "recv: " e.data)))
        ws-on-close (fn [] (log "disconnected") (set-ws nil))
        ws-on-close (fn [_ e]
                      (log (.."disconnected: " (if e.reason e.reason "")))
                      (set-ws nil))
        connect
        (fn []
          (if ws (log "already connected")
              (set-ws (create-websocket
                       {:ws-url ws-url
                        :on-open ws-on-open
                        :on-error ws-on-error
                        :on-message ws-on-message
                        :on-close ws-on-close}))))
        disconnect (fn [] (if ws (log "already disconnected") (: ws :close)))
        send-message (fn [message]
              (set-ws (fn [_ ws]
                        (if ws ws
                            (create-websocket
                             {:ws-url ws-url
                              :on-open ws-on-open
                              :on-error ws-on-error
                              :on-message ws-on-message
                              :on-close ws-on-close}))))))
        disconnect (fn [] (print "App.disconnect")
                     (if (not ws) (log "already disconnected") (: ws :close)))
        send-message (fn [message] (print "App.send-message")
                       (if (not ws) (log "please connect first")
                           (log (.. "send: " message) (: ws :send message))))]
    (c! [React.Fragment {} 
         [WebSocketDebugForm {:connect connect
                              :disconnect disconnect
                              :on-message send-message}]
                           (log (.. "send: " message) (: ws :send message))))
        debug-form (c! [WebSocketDebugForm {:connect connect
                                            :disconnect disconnect
                                            :on-message send-message}])]
    (set js.global.fennelLog (fn [_ message] (log message)))
    (c! [<> {}
         [:div {} (if ws "WebSocket connected" "Websocket not connected")]
         debug-form
         [Log {} log-messages]])))

(: ReactDOM :render

A app/src/app.html.fnl => app/src/app.html.fnl +41 -0
@@ 0,0 1,41 @@
(local map (fn [f tbl]
             (let [out {}]
               (each [i v (ipairs tbl)]
                 (tset out i (f v)))
               out)))

(local map-kv (fn [f tbl]
                (let [out {}]
                  (each [k v (pairs tbl)]
                    (table.insert out (f k v)))
                  out)))

(local to-attr (fn [k v]
                 (if (= v true) k
                     (.. k "=\"" v"\""))))

(local tag (fn [tag-name attrs]
             (assert (= (type attrs) "table") "Missing attrs table")
             (let [attr-str (table.concat (map-kv to-attr attrs) " ")]
               (.. "<" tag-name " " attr-str">"))))

(fn html [doc]
  (if (= (type doc) "string")
      doc
      (let [[tag-name attrs & body] doc]
        (.. (tag tag-name attrs)
            (table.concat (map html body) " ")
            "</" tag-name ">"))))

(local ret
       (.. "<!doctype html>\n"
           (html [:html {}
                  [:head {}
                   [:meta {:charset "UTF-8"}]]
                  [:body {}
                   [:div {:id :react-root}]
                   [:script {:src "app.js"}]]])))

(print ret)

ret

M app/src/react-helpers.fnl => app/src/react-helpers.fnl +13 -0
@@ 1,3 1,5 @@
(require-macros :src.react-macros)

(local React (require "react"))

(fn use-state [initial]


@@ 14,13 16,24 @@
(fn use-effect [effect dependencies]
  (React.useEffect nil effect dependencies))

(fn use-layout-effect [effect dependencies]
  (React.useLayoutEffect nil effect dependencies))

(fn use-previous [value]
  (let [ref (use-ref)]
    (use-effect (fn [] (set ref.current value)))
    ref.current))

(fn get-children-as-array [maybe-children]
  (if (: js.global.Array :isArray maybe-children)
      maybe-children
      (js! [maybe-children])))

{:use-state use-state
 :use-effect use-effect
 :use-layout-effect use-layout-effect
 :use-ref use-ref
 :use-previous use-previous
 :create-context create-context
 :use-context use-context
 :get-children-as-array get-children-as-array}

M app/src/react-macros.fnl => app/src/react-macros.fnl +20 -2
@@ 52,8 52,26 @@
      (create-component arg1 ...)))

(fn component! [name args ...]
  (let [padded-args `[_ @(unpack args)]]
    `(fn @name @padded-args @...)))
  (let [tab (gensym)
        something (gensym)
        child-tab (gensym)
        key (gensym)
        val (gensym)
        fun (gensym)
        index-fun (gensym)
        new-index-fun (gensym)]
    `(local @name
            (let [@fun (fn [_ _ @(unpack args)] @...)
                  @tab {:displayName @(tostring name)
                        :name @(tostring name)
                        :length @(# args)}
                  @index-fun (fn [_ @key] (rawget @tab @key))
                  @new-index-fun (fn [_ @key @val] (rawset @tab @key @val))]
              (setmetatable
               @tab {:__index @index-fun
                     :__newindex @new-index-fun
                     :__call @fun})
              (js.createproxy @tab :arrow_function)))))


{:js! js!

M app/webpack.config.js => app/webpack.config.js +3 -0
@@ 1,5 1,8 @@
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'app.js'
  },
  module: {
    rules: [
      {

A db/.gitignore => db/.gitignore +2 -0
@@ 0,0 1,2 @@
migrations/*.lua
config.lua
\ No newline at end of file

A db/config.fnl => db/config.fnl +5 -0
@@ 0,0 1,5 @@
{:host :db
 :port 5432
 :database :fennel-openresty
 :user :fennel-openresty
 :password "go fennel!"}

A db/makefile => db/makefile +15 -0
@@ 0,0 1,15 @@
.PHONY: all
all: config.lua migrations/create-users-table.lua migrations/create-messages-table.lua

.PHONY: clean
clean:
	rm -f ./config.lua
	rm -f ./migrations/*.lua

config.lua: config.fnl
	../modules/fennel/fennel --compile $< > $@

migrations/create-users-table.lua: migrations/create-users-table.fnl
	../modules/fennel/fennel --compile $< > $@
migrations/create-messages-table.lua: migrations/create-messages-table.fnl
	../modules/fennel/fennel --compile $< > $@

A db/migrations/create-messages-table.fnl => db/migrations/create-messages-table.fnl +18 -0
@@ 0,0 1,18 @@
(local pgmoon (require :pgmoon))
(local dbconfig (require :db.config))
(local pg (pgmoon.new dbconfig))

(local pg-status (assert (: pg :connect)))

(local messages-created (assert (: pg :query "
create table messages (
  id bigserial primary key,
  message text,
  response text,
  timestamp timestamp with time zone default current_timestamp
);
")))

(print (.. "creating messages table - success?: " messages-created))

messages-created

A db/migrations/create-users-table.fnl => db/migrations/create-users-table.fnl +21 -0
@@ 0,0 1,21 @@
(local pgmoon (require :pgmoon))
(local dbconfig (require :db.config))
(local fennelview (require :modules.fennel.fennelview))
(local pg (pgmoon.new dbconfig))

(local pg-status (assert (: pg :connect)))

(local users-table-created (assert (: pg :query "
create extension if not exists pgcrypto;
create table if not exists users (
  id uuid not null default gen_random_uuid() primary key,
  email text not null,
  password text not null
);
")))

# hello world

(print (.. "creating users table: " (fennelview users-table-created)))

users-table-created

A docker-compose.yml => docker-compose.yml +34 -0
@@ 0,0 1,34 @@
version: '3'
services:
  web:
    build: .
    depends_on:
      - db
    ports:
      - "8090:80"
    volumes:
      - .:/app
    networks:
      nw:
        ipv4_address: 172.28.0.101
  db:
    image: postgres
    restart: always
    ports:
      - "5443:5432"
    volumes:
      - ./db/data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: fennel-openresty
      POSGRES_PASSWORD: go fennel!
      POSTGRES_DB: fennel-openresty
    networks:
      nw:
        ipv4_address: 172.28.0.102

networks:
  nw:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

M makefile => makefile +9 -1
@@ 1,5 1,5 @@
.PHONY: all
all: server app
all: server app db

.PHONY: clean
clean: clean-server clean-app


@@ 22,3 22,11 @@ app:
.PHONY: clean-app
clean-app:
	$(MAKE) -C app clean

.PHONY: db
db:
	$(MAKE) -C db all

.PHONY: clean-db
clean-db:
	$(MAKE) -C db clean

M nginx/conf/nginx.conf => nginx/conf/nginx.conf +10 -7
@@ 1,22 1,25 @@
worker_processes  1;
error_log logs/error.log;
worker_processes 1;
events {
    worker_connections 1024;
}
http {
    include /usr/local/openresty/nginx/conf/mime.types;
    server {
        resolver 127.0.0.1;
        listen 80;
        location / {
            default_type text/html;
            content_by_lua_file server/server.lua;
            lua_code_cache off;
            index app.html;
            alias /app/app/dist/;
        }
        location /app.js {
            alias app/dist/main.js;
            alias /app/app/dist/app.js;
        }
        location /api {
            content_by_lua_file /app/server/server.lua;
            lua_code_cache off;
        }
        location /wss {
            content_by_lua_file server/wss.lua;
            content_by_lua_file /app/server/wss.lua;
            lua_code_cache off;
        }
    }

M server/html.fnl => server/html.fnl +1 -1
@@ 30,4 30,4 @@
      (let [[tag-name attrs & body] doc]
        (.. (tag tag-name attrs)
            (table.concat (map html body) " ")
"</" tag-name ">"))))
            "</" tag-name ">"))))

M server/server.fnl => server/server.fnl +5 -10
@@ 1,13 1,8 @@
(global ngx ngx)
(local pgmoon (require :pgmoon))
(local html (require :server.html))
(global _G (. (getmetatable _G) :__index))
(local fennel (require :modules.fennel.fennel))
(local fennelview (require :modules.fennel.fennelview))

(local homepage
       [:html {}
        [:head {}
         [:meta {:charset "UTF-8"}]
         [:script {} script]]
        [:body {}
         [:div {:id :react-root}]
         [:script {:src "app.js"}]]])

(ngx.say (.. "<!doctype html>\n" (html homepage)))
(ngx.say (.. "<!doctype html>\n" (html [:div {} :WTF])))

M server/wss.fnl => server/wss.fnl +84 -38
@@ 1,40 1,86 @@
(global ngx ngx)
(local server (require :resty.websocket.server))
(local (wb, err) (: server :new {:timeout 60000 :max_payload_len 65535}))

(if (not wb)
    (do (ngx.log ngx.ERR "failed to connect to new websocket: ", err)
        (ngx.exit 444))

    (while true
      (local (data typ err) (: wb :recv_frame))
      (if wb.fatal
          (do (ngx.log ngx.ERR "failed to receive frame: ", err)
              (ngx.exit 444))

          (not data)
          (let [(bytes err) (: wb :send_ping)]
            (when (not bytes)
              (ngx.log ngx.ERR "failed to send ping: " err)
              (ngx.exit 444)))

          (= typ :close)
          (: wb :send_close)

          (= typ :ping)
          (let [(bytes err) (: wb :send_pong)]
            (when (not bytes)
              (ngx.log ngx.ERR "failed to send pong: " err)
              (ngx.exit 444)))

          (= typ :pong)
          (do (ngx.log ngx.INFO "client ponged")
              (: wb :send_close))

          (= typ :text)
          (let [(bytes err) (: wb :send_text data)]
            (if (not bytes)
                (do (ngx.log ngx.ERR "failed to send text: " err)
                    (ngx.exit 444))))

          (: wb :send_close))))

(local pgmoon (require :pgmoon))

(local pg
       (pgmoon.new
        {:host :172.28.0.102
         :port :5432
         :database :fennel-openresty
         :user :fennel-openresty
         :password "go fennel!"}))
(local (pg-status pg-err) (: pg :connect))
((fn [] (when (not pg-status)
          (ngx.log ngx.ERR (.. "error connecting to db: " (tostring pg-err))))))

(fn log-message-to-db [message response]
  (when pg-status
    (let [query (.. "insert into messages (message, response) values ("
                    (: pg :escape_literal message) ","
                    (: pg :escape_literal response) ");")
          (result err) (: pg :query query)]
      (when (not result)
        (ngx.log ngx.ERR (.. "error logging message: " (tostring err) " / " (tostring result)))))))

(global _G (. (getmetatable _G) :__index))
(local fennel (require :modules.fennel.fennel))
(local fennelview (require :modules.fennel.fennelview))

(fn eval-message [message]
  (let [(ok result) (xpcall fennel.eval fennel.traceback message)]
    (if ok (fennelview result) result)))

(fn wss [] ;; must be wrapped in a function to avoid a top-level vararg
  (local (wb, err) (: server :new {:timeout 10000 :max_payload_len 65535}))
  (var continue true)
  (when (not wb)
    (ngx.log ngx.ERR "failed to connect to new websocket: ", err)
    (ngx.exit 444)
    (set continue false))
  (while continue
    (local (data typ err) (: wb :recv_frame))
    (if (and (not data) (not (string.find err :timeout 1 true)))
        (do
          (ngx.log ngx.ERR "failed to receive a frame: " err)
          (set continue false))
        
        (= typ :close)
        (let [(bytes err) (: wb :send_close 1000 "close frame")]
          (if (not bytes)
              (ngx.log ngx.ERR "failed to send the close frame: " err)
              (ngx.log ngx.INFO "closing with status code " err " and message " data))
          (set continue false))

        (= typ :ping)
        (let [(bytes err) (: wb :send_pong data)]
          (when (not bytes)
            (ngx.log ngx.ERR "failed to send frame: " err)
            (set continue false)))
        
        (= typ :pong) nil
        
        (= typ :text)
        (let [evaled-message (eval-message data)
              (bytes err) (: wb :send_text evaled-message)]
          (ngx.log ngx.INFO data)
          (ngx.log ngx.INFO evaled-message)
          (log-message-to-db data evaled-message)
          (when (not bytes)
            (ngx.log ngx.ERR "failed to send text: " err)
            (ngx.exit 444)
            (set continue false)))

        (not (string.find err :timeout 1 true))
        (let [(bytes err) (: wb :send_text (.. "unknown frame type: " (tostring typ)
                                               " data: " (tostring data)
                                               " err: " (tostring err)))]
          (when (not bytes) 
            (ngx.log ngx.ERR "failed to send a text frame: " err)
            (ngx.exit 444)
            (set continue false)))))

  (let [(bytes err) (: wb :send_close 1000 "close frame")]
    (when (not bytes) (ngx.log ngx.ERR "failed to send the close frame: " err))))

(wss)