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))