From 113196abb0aaba48d918553f55f4f71f64443428 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Wed, 22 Apr 2020 14:33:35 -0700 Subject: [PATCH] snapshot --- app/makefile | 2 - app/src/app.fnl | 6 +- app/src/app.html.fnl | 36 +++---- fennelconf-2019-presentation.org | 161 +++++++++++++++++++++++++++++++ makefile | 3 +- modules/lua-resty-http | 2 +- nginx/conf/nginx.conf | 4 +- server/server.fnl | 76 ++++++++++----- server/wss.fnl | 1 - 9 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 fennelconf-2019-presentation.org diff --git a/app/makefile b/app/makefile index 4e3295c..31d080a 100644 --- a/app/makefile +++ b/app/makefile @@ -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 diff --git a/app/src/app.fnl b/app/src/app.fnl index 7d963b3..747d295 100644 --- a/app/src/app.fnl +++ b/app/src/app.fnl @@ -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!") diff --git a/app/src/app.html.fnl b/app/src/app.html.fnl index d21e927..d829b15 100644 --- a/app/src/app.html.fnl +++ b/app/src/app.html.fnl @@ -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) " ") "")))) diff --git a/fennelconf-2019-presentation.org b/fennelconf-2019-presentation.org new file mode 100644 index 0000000..d11e79c --- /dev/null +++ b/fennelconf-2019-presentation.org @@ -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? diff --git a/makefile b/makefile index c7ab913..f075b11 100644 --- a/makefile +++ b/makefile @@ -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 $^ > $@ diff --git a/modules/lua-resty-http b/modules/lua-resty-http index f71e970..a6bd2e0 160000 --- a/modules/lua-resty-http +++ b/modules/lua-resty-http @@ -1 +1 @@ -Subproject commit f71e9708a3fd0ff4179d4b0d770c91fcf98d0042 +Subproject commit a6bd2e0eb1390e330e4fb10a48cced5a1f21fb66 diff --git a/nginx/conf/nginx.conf b/nginx/conf/nginx.conf index a680684..d312510 100644 --- a/nginx/conf/nginx.conf +++ b/nginx/conf/nginx.conf @@ -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; diff --git a/server/server.fnl b/server/server.fnl index 96b1e8d..306c785 100644 --- a/server/server.fnl +++ b/server/server.fnl @@ -3,7 +3,6 @@ (local cjson (require :cjson)) (local html (fn [...] (.. "" ((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) diff --git a/server/wss.fnl b/server/wss.fnl index 23dcb92..7c91d80 100644 --- a/server/wss.fnl +++ b/server/wss.fnl @@ -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)) -- 2.45.2