~benaiah/fennel-openresty

113196abb0aaba48d918553f55f4f71f64443428 — Benaiah Mischenko 1 year, 7 months ago f9b53d0 master
snapshot
M app/makefile => app/makefile +0 -2
@@ 14,8 14,6 @@ dist/app.js: src/react-helpers.lua src/http-helpers.lua src/app.lua src/index.js
	yarn && yarn run build
COMPONENTS_SRC = $(wildcard src/components/*.fnl)
COMPONENTS_OBJ = $(patsubst src/components/%.fnl, src/components/%.lua, $(COMPONENTS_SRC))
lib/fennel/fennelview.lua: lib/fennel/fennelview.fnl
	modules/fennel/fennel --compile $< > $@
src/react-helpers.lua: src/react-helpers.fnl
	modules/fennel/fennel --compile $< > $@
src/http-helpers.lua: src/http-helpers.fnl

M app/src/app.fnl => app/src/app.fnl +3 -3
@@ 160,9 160,9 @@
      (log-with dispatch :info "connected")
      (dispatch {:type :ws-connected})
      (when state.user.token
        (: state.ws.connection :send
           (: JSON :stringify
              (js! {:action :auth :token state.user.token})))))))
        (state.ws.connection:send
         (: JSON :stringify
            (js! {:action :auth :token state.user.token})))))))

(fn ws-when-error [_ dispatch event]
  (log-with dispatch :info "websocket error!")

M app/src/app.html.fnl => app/src/app.html.fnl +18 -18
@@ 1,28 1,28 @@
(local map (fn [f tbl]
             (let [out {}]
               (each [i v (ipairs tbl)]
                 (tset out i (f v)))
               out)))
(fn map [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)))
(fn map-kv  [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"\""))))
(fn to-attr [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 tag [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 [doctype]
  (if (= (type doctype) "string")
      doctype
      (let [[tag-name attrs & body] doc]
      (let [[tag-name attrs & body] doctype]
        (.. (tag tag-name attrs)
            (table.concat (map html body) " ")
            "</" tag-name ">"))))

A fennelconf-2019-presentation.org => fennelconf-2019-presentation.org +161 -0
@@ 0,0 1,161 @@
* Full Stack Fennel; or, Overengineering as an Art Form 
* Introduction
- My name is Benaiah Mischenko (he/him)
- Started using Fennel for Love2D games
- Got curious about using Fennel for web apps
- Turned the Fennel compiler into a single page app for fun
* Demonstration
* OpenResty
- https://openresty.org
- A web platform for Lua using Nginx
* Docker
- Consistent, rebuildable runtime environment
- OpenResty Docker image: openresty/openresty
- [[file:Dockerfile][Dockerfile]]
* docker-compose
- Spin up a multi-container app with one command
- Lets us run our DB /in development/ easily (Don't put production DBs in containers!)
- [[file:docker-compose.yml][docker-compose.yml]]
* Server Setup
- [[file:makefile][makefile]]
- [[file:nginx/conf/nginx.conf][nginx/conf/nginx.conf]]
* PostgreSQL
- Use PostgreSQL docker image for dev
- [[file:db/makefile][db/makefile]]
* PostgreSQL: Migrations
- Connects using the same DB config and libraries
- Run in the app's container using ~resty~
* API
- API for compiling Fennel
- Authenticated with JWT tokens
- PostgreSQL for user/password storage
* API
[[file:server/server.fnl][server/server.fnl]]
* JWT Authentication
- Stateless JSON tokens signed with a shared secret
- [[file:server/jwt.fnl][server/jwt.fnl]]
- [[file:server/server.fnl][server/server.fnl]]
- [[file:app/src/components/LoginForm.fnl][app/src/components/LoginForm.fnl]]
* Single Page App
- [[file:app/makefile][app/makefile]]
- [[file:app/src/app.html.fnl][app/src/app.html.fnl]]
* Fengari
- https://github.com/fengari-lua/fengari
- JS runtime for Lua
- Lua tables and JS objects are very distinct
- Function arguments are off by one
* Building JS
- Webpack
- fengari-loader
- [[file:app/webpack.config.js][app/webpack.config.js]]
* Fengari: Macros!
~js!~ - creates a JS object/array structure based on the literal structure of its argument
#+BEGIN_SRC fennel
(js! {:hello {:world true} :emptyArray []})
;; js equivalent: {hello: {world: true}, emptyArray: []}

(let [x {:hello {:world true} :emptyArray []}] (js! x))
;; doesn't work correctly - js! is a macro
#+END_SRC
* React
- Gives us an easy way to create a UI 
* React: Macros!
~component!~ - creates a React functional component and uses a JS Proxy to set the display name.
~c!~ - calls React.createComponent on a component, its attributes (processed with ~js!~), and its children; or does so recursively on each element of a tree of sequence literals.
* React: Macros!
#+BEGIN_SRC fennel
(component! Example [{:color color :children children}]
  (c! :div {:style {:color color}} children))
(: ReactDOM :render
   (c! [Example {:color blue} [:span {} :Hello ", " :world!]])
   (: js.global.document :getElementById :react-root))
#+END_SRC
* React: Macros!
#+BEGIN_SRC fennel
(component! Example [{:color color :children children}]
  (c! :div {:style {:color color}} children))
(local props {:color blue})
(: ReactDOM :render
   (c! [Example props [:span {} "Hello, world!"]])
   (: js.global.document :getElementById :react-root))
#+END_SRC
This doesn't work!
* React: Macros!
#+BEGIN_SRC fennel
(component! Example [{:color color :children children}]
  (c! :div {:style {:color color}} children))
(local props (js! {:color blue}))
(: ReactDOM :render
   (c! [Example props [:span {} "Hello, world!"]])
   (: js.global.document :getElementById :react-root))
#+END_SRC
This does work.
* React: Macros!
[[file:app/src/react-macros.fnl][app/src/react-macros.fnl]]
* React: Hooks
Hooks are a new React feature that lets you manage component lifecycles and side effects without needing JS classes. This is perfect for use with ~component!~, since that macro creates functional components.
* React: Hooks
~useEffect~ - run side effects when component props change
~useRef~ - get a persistent mutable handle (useful for manual DOM changes or retrieving up-to-date values in closures)
* React: useRef + useEffect
#+BEGIN_SRC fennel
(fn use-scroll-to-last-child [deps]
  (let [container-ref (use-ref nil)]
    (use-effect
     (fn []
       (when (has-last-child container-ref.current)
         (: container-ref.current.lastChild
            :scrollIntoView (js! {}))))
     deps) container-ref))
#+END_SRC
* React: useRef + useEffect
[[file:app/src/components/ScrollingContainer.fnl][app/src/components/ScrollingContainer.fnl]]
* React: Hooks
~useState~ - persist state between renders, and rerender when state changes
~useReducer~ - similar to ~useState~, but lets you use a reducer function to generate a new state based on a dispatched action
* React: useState
#+BEGIN_SRC fennel
(component! InputForm [{:on-submit on-submit}]
  (let [(input-val set-input-val) (use-state "")]
    (c! [:form {:onSubmit on-submit}
         [:input {:type text :value input-val
                  :onChange
                  (fn [_ {:target {:value v}}]
                    (set-input-val v))}]
         [:button {:type submit} :Submit]])))
#+END_SRC
* React: useState
[[file:app/src/components/LoginForm.fnl][app/src/components/LoginForm.fnl]]
* React: useReducer
#+BEGIN_SRC fennel
(local initial {:num 0})
(fn reducer [state action]
  (if (= action :inc) {:num (+ state.num 1)} state)
(component! App []
  (let [(state dispatch) (use-reducer reducer initial)]
    (c! [:div {}
         [:span {} state.num]
         [:button {:onClick (fn [] (dispatch :inc))}]]))))
#+END_SRC
* React: useReducer
[[file:app/src/app.fnl][app/src/app.fnl]]
* React: useContext
~useContext~ - provide a context value that can be retrieved at any point below it in the UI tree
* React: useContext
- [[file:app/src/components/DispatchContext.fnl][app/src/components/DispatchContext.fnl]]
- [[file:app/src/app.fnl][app/src/app.fnl]]
- [[file:app/src/components/LoginForm.fnl][app/src/components/LoginForm.fnl]]
* Websockets
- Lets us maintain a persistent connection to the server
- [[file:server/wss.fnl][server/wss.fnl]]
- [[file:app/src/app.fnl][app/src/app.fnl]]
* Fennel Wishlist
- Easier way of sharing a first-class state between macros (e.g., set the symbol used for ~React~ in ~c!~ and ~component!~ for a whole file)
- Version of ~match~ with a pluggable type checks (current ~match~ doesn't work on JS objects, like destructuring does)
- Namespaces for macros (~rm.component!~ vs ~component!~)
* Stuff I Didn't Get To
- Replace JSON with fennelview (reader needed)
- Compile-time DSL for SQL with macros
- Make external HTTP requests from OpenResty
- Sending email confirmation using a message broker (nsqd)
* Questions?

M makefile => makefile +2 -1
@@ 5,13 5,14 @@ all: server app db
clean: clean-server clean-app

.PHONY: server
server: server/server.lua server/html.lua server/wss.lua server/jwt.lua
server: server/server.lua server/html.lua server/wss.lua server/jwt.lua modules/fennel/fennelview.lua

.PHONY: clean-server
clean-server:
	rm -f server/*.lua

server/server.lua: server/server.fnl ; modules/fennel/fennel --compile $^ > $@
modules/fennel/fennelview.lua: modules/fennel/fennelview.fnl ; modules/fennel/fennel --compile $^ > $@
server/html.lua: server/html.fnl ; modules/fennel/fennel --compile $^ > $@
server/jwt.lua: server/jwt.fnl ; modules/fennel/fennel --compile $^ > $@
server/wss.lua: server/wss.fnl ; modules/fennel/fennel --compile $^ > $@

M modules/lua-resty-http => modules/lua-resty-http +1 -1
@@ 1,1 1,1 @@
Subproject commit f71e9708a3fd0ff4179d4b0d770c91fcf98d0042
Subproject commit a6bd2e0eb1390e330e4fb10a48cced5a1f21fb66

M nginx/conf/nginx.conf => nginx/conf/nginx.conf +2 -2
@@ 11,8 11,8 @@ http {
    server {
        set $fennel_proxy_host 'httpbin.org';
        set $fennel_proxy_port '80';
        set $fennel_proxy_path '/get';
        resolver 127.0.0.1;
        set $fennel_proxy_path '/get?arg=1';
        resolver 1.1.1.1;
        listen 80;
        location / {
            index app.html;

M server/server.fnl => server/server.fnl +52 -24
@@ 3,7 3,6 @@
(local cjson (require :cjson))
(local html (fn [...] (.. "<!doctype html>"
                          ((require :server.html) ...))))
(global _G (. (getmetatable _G) :__index))
(local fennel (require :modules.fennel.fennel))
(local fennelview (require :modules.fennel.fennelview))
(local lume (require :modules.lume.lume))


@@ 12,13 11,34 @@
(local merge lume.merge)

(local pg (pgmoon.new (require :db.config)))
(local pg-status (assert (: pg :connect)))
(local pg-status (assert (pg:connect)))

(local (headers headers-err) (ngx.req.get_headers))
(when headers-err (error headers-err))

(local method (ngx.req.get_method))
(local args (ngx.req.get_uri_args))

(fn serialize-args [args]
  (let [strings []]
    (var strings-length 0)
    (each [k v (pairs args)]
      (if (= (type v) :string)
          (do
            (set strings-length (+ strings-length 1))
            (tset strings strings-length (.. (ngx.escape_uri k) "=" (ngx.escape_uri v))))

          (= (type v) :table)
          (let [escaped-k (ngx.escape_uri k)]
            (each [i vv (ipairs v)]
              (set strings-length (+ strings-length 1))
              (tset strings strings-length (.. escaped-k "=" (ngx.escape_uri vv)))))

          (error "bad args")))
    (if (> strings-length 0) (table.concat strings :&) "")))

(fn say-serialized [tab]
  (if (and h (= headers.Accept "text/x-fennelview"))
  (if (and headers (= headers.Accept "text/x-fennelview"))
      (do (set ngx.header.Content-Type "text/x-fennelview")
          (ngx.say (fennelview tab {:indent ""})))
      (do (set ngx.header.Content-Type "application/json")


@@ 66,13 86,14 @@

;; returns a user id if successful
(fn authenticate-user-by-email [{:email email :password password}]
  (let [esc-email (: pg :escape_literal email)
        esc-password (: pg :escape_literal password)
  (ngx.log ngx.NOTICE (.. "email: " email " pw: " password))
  (let [esc-email (pg:escape_literal email)
        esc-password (pg:escape_literal password)
        query (.. "select id, email, confirmed from users where"
                     " email=lower(" esc-email ") and"
                     " password=crypt(" esc-password ", password)"
                     " limit 1;")
        result (: pg :query query)]
        result (pg:query query)]
    (and result (. result 1))))

(local authenticate-wrong-method-error-message


@@ 90,12 111,10 @@
          (if (and body.email body.password)
              (let [user (authenticate-user-by-email body)]
                (if user
                    (do (say-serialized
                         {:token (jwt.get-token user)}))
                    (say-serialized {:token (jwt.get-token user)})
                    (do (set ngx.status ngx.HTTP_UNAUTHORIZED)
                        (say-serialized
                         {:error {:message "Unauthorized"
                                  :code 401}}))))
                         {:error {:message "Unauthorized" :code 401}}))))
              (do (set ngx.status ngx.HTTP_BAD_REQUEST)
                  (say-serialized
                   {:error {:message "Bad request" :code 400}})))))


@@ 120,9 139,9 @@
       (let [{:id id} (. context user-key)
             query (when id
                     (.. "select email from users where id="
                         (: pg :escape_literal id) " limit 1;"))
                         (pg:escape_literal id) " limit 1;"))
             result (when query
                      (: pg :query query))
                      (pg:query query))
             email (when (and result (. result 1))
                     (. result 1 :email))]
         (if email (say-serialized {:id id :email email})


@@ 133,7 152,7 @@

(fn signup-route [context]
  (match method
    :POST ))
    :POST nil))

(fn path-key [] nil)
(fn confirm-signup-route [context]


@@ 149,14 168,23 @@
                                     :code 400}})))))

(fn test-httpbin-route []
  (set ngx.var.fennel_proxy_host "httpbin.org")
  (set ngx.var.fennel_proxy_port "80")
  (set ngx.var.fennel_proxy_path "/get?arg1=a")
  (let [res (ngx.location.capture "/proxy" )]
  (let [http (require :resty.http)
        httpc (http.new)
        args-string (serialize-args args)
        url (.. "http://httpbin.org/get?" args-string)
        _ (ngx.log ngx.NOTICE (.. "url: " url " args-string: " args-string))
        (res err) (httpc:request_uri url {:method :GET
                                          :keepalive_timeout 60000
                                          :keepalive_pool 10})]
    (if (not res)
        (ngx.say "failed to request: " err)
        (set ngx.header.Content-Type "text/plain")
        (ngx.say res.body))))
        (do
          (set ngx.status res.status)
          (each [k v (pairs res.headers)] (tset ngx.header k v))
          (ngx.say res.body)))))

(fn echo-args-route []
  (say-serialized args))

(fn get-route [path]
  (match path


@@ 166,17 194,17 @@
    [:api :user nil] user-route
    [:api :confirm-signup] confirm-signup-route
    [:api :test-httpbin nil] test-httpbin-route
    [:api :echo-args nil] echo-args-route
    _ unknown-route))

(fn string-ends-with [str ending]
  (or (= ending "") (= (string.sub str (* -1 (# ending))) ending)))

((fn router [uri]
   (let [trimmed-uri (if (string-ends-with uri "/")
                         (string.sub uri 1 -2)
                         uri)
(fn router [uri]
   (let [trimmed-uri (if (string-ends-with uri "/") (string.sub uri 1 -2) uri)
         path (lume.slice (lume.split trimmed-uri :/) 2)
         route (get-route path)
         context {path-key (lume.slice path 3)}]
     (route context)))
 ngx.var.uri)

(router ngx.var.uri)

M server/wss.fnl => server/wss.fnl +0 -1
@@ 25,7 25,6 @@
        (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))