From 7ca807318e5c17584408b7cf2a439ce953b5ca90 Mon Sep 17 00:00:00 2001 From: Tiago Azevedo Date: Thu, 7 May 2015 16:16:26 +0100 Subject: [PATCH] Added isomorphic cookie parsing --- lib/application.js | 183 ++++++++++++++++++++++++++++++++++++--- lib/server-rendering.js | 12 +-- lib/server.js | 17 ++-- lib/virtual-dom-utils.js | 3 +- package.json | 2 + spec/server_spec.ls | 6 +- src/application.ls | 61 +++++++++++-- src/server-rendering.ls | 4 +- src/server.ls | 15 ++-- src/virtual-dom-utils.ls | 2 + 10 files changed, 258 insertions(+), 47 deletions(-) diff --git a/lib/application.js b/lib/application.js index 2d8f417..1d4173c 100644 --- a/lib/application.js +++ b/lib/application.js @@ -1,11 +1,13 @@ (function(){ - var bluebird, cursor, dom, routes, serverRendering, domUtils, span, appComponent, initAppState, observePageChange; + var bluebird, cursor, dom, routes, serverRendering, cookie, domUtils, ref$, keys, each, Obj, span, appComponent, initAppState, observePageChange, parseReqCookies; bluebird = require('bluebird'); cursor = require('./cursor'); dom = require('./dom'); routes = require('./routes'); serverRendering = require('./server-rendering'); + cookie = require('cookie'); domUtils = require('./virtual-dom-utils'); + ref$ = require('prelude-ls'), keys = ref$.keys, each = ref$.each, Obj = ref$.Obj; span = dom.span; appComponent = React.createFactory(React.createClass({ displayName: 'arch-application', @@ -31,10 +33,11 @@ } } })); - initAppState = function(initialState, routeContext){ + initAppState = function(initialState, routeContext, cookies){ return cursor({ state: initialState, - route: routeContext + route: routeContext, + cookies: cookies }); }; observePageChange = function(rootTree, appState){ @@ -51,25 +54,53 @@ }, 0); }); }; + parseReqCookies = function(cookies){ + return Obj.map(function(it){ + return { + value: it + }; + })( + cookies); + }; module.exports = { create: function(app){ return { start: function(){ - var routeSet, path, rootDomNode, stateNode, serverState, appState, rootElement, root; + var routeSet, path, clientCookies, rootDomNode, stateNode, serverState, appState, rootElement, root; routeSet = app.routes(); path = location.pathname + location.search + location.hash; + clientCookies = parseReqCookies(cookie.parse(document.cookie)); rootDomNode = document.getElementById("application"); stateNode = document.getElementById("arch-state"); serverState = JSON.parse(stateNode.text); appState = serverState ? cursor(serverState) - : initAppState(app.getInitialState(), routes.resolve(routeSet, path)); + : initAppState(app.getInitialState(), routes.resolve(routeSet, pathname), clientCookies); app.start(appState); rootElement = appComponent({ appState: appState, routes: routeSet }); root = React.render(rootElement, rootDomNode); + appState.get('cookies').onChange(function(cookies){ + return each(function(k){ + var c; + c = cookies[k] + ? cookies[k].value + : cookies[k]; + if (!(clientCookies[k] && deepEq$(clientCookies[k].value, JSON.stringify(c), '==='))) { + if (c === null || c === undefined) { + return document.cookie = cookie.serialize(k, null, { + expires: new Date() + }); + } else { + return document.cookie = cookie.serialize(k, c, cookies[k].options); + } + } + })( + keys( + cookies)); + }); appState.onChange(function(){ return root.setState({ appState: appState @@ -78,12 +109,34 @@ observePageChange(root, appState); return routes.start(app.routes(), appState); }, - render: function(path){ - var routeSet, appState, transaction, rootElement; + render: function(req, res){ + var path, routeSet, clientCookies, appState, transaction, rootElement; + path = req.originalUrl; routeSet = app.routes(); - appState = initAppState(app.getInitialState(), routes.resolve(routeSet, path)); + clientCookies = parseReqCookies(req.cookies); + appState = initAppState(app.getInitialState(), null, clientCookies); transaction = appState.startTransaction(); + appState.get('cookies').onChange(function(cookies){ + return each(function(k){ + var c; + c = cookies[k] + ? cookies[k].value + : cookies[k]; + if (!(clientCookies[k] && deepEq$(clientCookies[k].value, JSON.stringify(c), '==='))) { + if (c === null || c === undefined) { + return res.clearCookie(k); + } else { + return res.cookie(k, c, cookies[k].options); + } + } + })( + keys( + cookies)); + }); app.start(appState); + appState.get('route').update(function(){ + return routes.resolve(routeSet, path); + }); rootElement = appComponent({ appState: appState, routes: routeSet @@ -94,17 +147,39 @@ return [meta, appState.deref(), React.renderToString(rootElement)]; }); }, - processForm: function(path, postData){ - var routeSet, appState, transaction, rootElement, location; + processForm: function(req, res){ + var path, clientCookies, routeSet, appState, transaction, rootElement, location; + path = req.originalUrl; + clientCookies = parseReqCookies(req.cookies); routeSet = app.routes(); - appState = initAppState(app.getInitialState(), routes.resolve(routeSet, path)); + appState = initAppState(app.getInitialState(), null, clientCookies); + appState.get('cookies').onChange(function(cookies){ + return each(function(k){ + var c; + c = cookies[k] + ? cookies[k].value + : cookies[k]; + if (!(clientCookies[k] && deepEq$(clientCookies[k].value, JSON.stringify(c), '==='))) { + if (c === null || c === undefined) { + return res.clearCookie(k); + } else { + return res.cookie(k, c, cookies[k].options); + } + } + })( + keys( + cookies)); + }); transaction = appState.startTransaction(); app.start(appState); + appState.get('route').update(function(){ + return routes.resolve(routeSet, path); + }); rootElement = appComponent({ appState: appState, routes: routeSet }); - location = serverRendering.processForm(rootElement, appState, postData, path); + location = serverRendering.processForm(rootElement, appState, req.body, path); return appState.endTransaction(transaction).then(function(){ var meta, body; meta = serverRendering.routeMetadata(rootElement, appState); @@ -115,4 +190,88 @@ }; } }; + function deepEq$(x, y, type){ + var toString = {}.toString, hasOwnProperty = {}.hasOwnProperty, + has = function (obj, key) { return hasOwnProperty.call(obj, key); }; + var first = true; + return eq(x, y, []); + function eq(a, b, stack) { + var className, length, size, result, alength, blength, r, key, ref, sizeB; + if (a == null || b == null) { return a === b; } + if (a.__placeholder__ || b.__placeholder__) { return true; } + if (a === b) { return a !== 0 || 1 / a == 1 / b; } + className = toString.call(a); + if (toString.call(b) != className) { return false; } + switch (className) { + case '[object String]': return a == String(b); + case '[object Number]': + return a != +a ? b != +b : (a == 0 ? 1 / a == 1 / b : a == +b); + case '[object Date]': + case '[object Boolean]': + return +a == +b; + case '[object RegExp]': + return a.source == b.source && + a.global == b.global && + a.multiline == b.multiline && + a.ignoreCase == b.ignoreCase; + } + if (typeof a != 'object' || typeof b != 'object') { return false; } + length = stack.length; + while (length--) { if (stack[length] == a) { return true; } } + stack.push(a); + size = 0; + result = true; + if (className == '[object Array]') { + alength = a.length; + blength = b.length; + if (first) { + switch (type) { + case '===': result = alength === blength; break; + case '<==': result = alength <= blength; break; + case '<<=': result = alength < blength; break; + } + size = alength; + first = false; + } else { + result = alength === blength; + size = alength; + } + if (result) { + while (size--) { + if (!(result = size in a == size in b && eq(a[size], b[size], stack))){ break; } + } + } + } else { + if ('constructor' in a != 'constructor' in b || a.constructor != b.constructor) { + return false; + } + for (key in a) { + if (has(a, key)) { + size++; + if (!(result = has(b, key) && eq(a[key], b[key], stack))) { break; } + } + } + if (result) { + sizeB = 0; + for (key in b) { + if (has(b, key)) { ++sizeB; } + } + if (first) { + if (type === '<<=') { + result = size < sizeB; + } else if (type === '<==') { + result = size <= sizeB + } else { + result = size === sizeB; + } + } else { + first = false; + result = size === sizeB; + } + } + } + stack.pop(); + return result; + } + } }).call(this); diff --git a/lib/server-rendering.js b/lib/server-rendering.js index cc380a9..78836ab 100644 --- a/lib/server-rendering.js +++ b/lib/server-rendering.js @@ -1,7 +1,7 @@ (function(){ - var domUtils, ref$, difference, filter, first, keys, Obj, ReactServerRenderingTransaction, ReactDefaultBatchingStrategy, instantiateReactComponent, ReactUpdates, redirectLocation, configureReact, renderTree, fakeEvent, changeInputs, submitForm, processForm, routeMetadata, resetRedirect, redirect; + var domUtils, ref$, difference, filter, first, keys, Obj, each, ReactServerRenderingTransaction, ReactDefaultBatchingStrategy, instantiateReactComponent, ReactUpdates, redirectLocation, configureReact, renderTree, fakeEvent, changeInputs, submitForm, processForm, routeMetadata, resetRedirect, redirect; domUtils = require('./virtual-dom-utils'); - ref$ = require('prelude-ls'), difference = ref$.difference, filter = ref$.filter, first = ref$.first, keys = ref$.keys, Obj = ref$.Obj; + ref$ = require('prelude-ls'), difference = ref$.difference, filter = ref$.filter, first = ref$.first, keys = ref$.keys, Obj = ref$.Obj, each = ref$.each; ReactServerRenderingTransaction = require('react/lib/ReactServerRenderingTransaction'); ReactDefaultBatchingStrategy = require('react/lib/ReactDefaultBatchingStrategy'); instantiateReactComponent = require('react/lib/instantiateReactComponent'); @@ -43,9 +43,11 @@ }; changeInputs = function(inputs, postData){ return each(function(it){ - it.props.onChange(fakeEvent(it, { - value: postData[it.props.name] - })); + if (it.props.onChange) { + it.props.onChange(fakeEvent(it, { + value: postData[it.props.name] + })); + } return ReactUpdates.flushBatchedUpdates(); })( inputs); diff --git a/lib/server.js b/lib/server.js index 4def92c..6a73440 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,5 +1,5 @@ (function(){ - var express, fs, path, jade, bluebird, bodyParser, bundler, LiveScript, register, ref$, each, values, filter, find, flatten, map, first, defaults, archGet, archPost, __template, layoutRender; + var express, fs, path, jade, bluebird, bodyParser, bundler, LiveScript, register, cookieParser, ref$, each, values, filter, find, flatten, map, first, defaults, archGet, archPost, __template, layoutRender; express = require('express'); fs = require('fs'); path = require('path'); @@ -9,6 +9,7 @@ bundler = require('./bundler'); LiveScript = require('LiveScript'); register = require('babel/register'); + cookieParser = require('cookie-parser'); ref$ = require('prelude-ls'), each = ref$.each, values = ref$.values, filter = ref$.filter, find = ref$.find, flatten = ref$.flatten, map = ref$.map, first = ref$.first; defaults = { environment: process.env.NODE_ENV || 'development', @@ -31,13 +32,13 @@ app = options.app || require(options.paths.app.rel); get = function(req, res){ console.log("GET", req.originalUrl); - return archGet(app, req.originalUrl, options).spread(function(status, headers, body){ + return archGet(app, req, res, options).spread(function(status, headers, body){ return res.status(status).set(headers).send(body); }); }; post = function(req, res){ console.log("POST", req.originalUrl, req.body); - return archPost(app, req.originalUrl, req.body, options).spread(function(status, headers, body){ + return archPost(app, req, res, options).spread(function(status, headers, body){ return res.status(status).set(headers).send(body); }); }; @@ -46,7 +47,7 @@ var server, listener; server = express().use("/" + options.paths['public'], express['static'](path.join(options.paths.app.abs, options.paths['public']))).use(bodyParser.urlencoded({ extended: false - })).get('*', get).post('*', post); + })).use(cookieParser()).get('*', get).post('*', post); bundler.bundle(options.paths, options.environment === 'development', function(ids){ var done, id, parents, e; done = []; @@ -108,15 +109,15 @@ } }; }; - archGet = function(app, url, options){ - return app.render(url).spread(function(meta, appState, body){ + archGet = function(app, req, res, options){ + return app.render(req, res).spread(function(meta, appState, body){ var html; html = layoutRender(meta, body, appState, options); return [200, {}, html]; }); }; - archPost = function(app, url, postData, options){ - return app.processForm(url, postData).spread(function(meta, appState, body, location){ + archPost = function(app, req, res, options){ + return app.processForm(req, res).spread(function(meta, appState, body, location){ var html; if (!body) { return [ diff --git a/lib/virtual-dom-utils.js b/lib/virtual-dom-utils.js index 232551f..1ff2b74 100644 --- a/lib/virtual-dom-utils.js +++ b/lib/virtual-dom-utils.js @@ -1,6 +1,7 @@ (function(){ - var testUtils, extractRoute, formElements, routeMetadata, toString$ = {}.toString; + var testUtils, ref$, filter, find, any, extractRoute, formElements, routeMetadata, toString$ = {}.toString; testUtils = React.addons.TestUtils; + ref$ = require('prelude-ls'), filter = ref$.filter, find = ref$.find, any = ref$.any; extractRoute = function(tree){ var routes; routes = testUtils.findAllInRenderedTree(tree, function(it){ diff --git a/package.json b/package.json index fb48e8f..b6ca5a3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "babel-loader": "^4.1.0", "bluebird": "^2.3.11", "body-parser": "^1.10.1", + "cookie": "^0.1.2", + "cookie-parser": "^1.3.4", "envify-loader": "^0.1.0", "express": "^4.10.6", "immutable": "^3.4.1", diff --git a/spec/server_spec.ls b/spec/server_spec.ls index ffa1a5d..82db993 100644 --- a/spec/server_spec.ls +++ b/spec/server_spec.ls @@ -19,13 +19,13 @@ describe "server" (_) -> inst := server app: app it "passes through to application's server rendering" !-> - inst.get app, 'url', paths: { public: 'dist' } + inst.get app, {original-url: 'url', cookies:{}}, {}, paths: { public: 'dist' } # Test that the method that renders a route to a string has been called # with a URL an anonymous function. - expect app.render .to-have-been-called-with 'url' + expect app.render .to-have-been-called-with { original-url: 'url', cookies:{} }, {} it "renders the into a provided layout" (done) !-> - inst.get app, 'app-state', paths: { layouts: support-templates, public: 'dist' } + inst.get app, {original-url: 'app-state', cookies:{}}, {}, paths: { layouts: support-templates, public: 'dist' } .spread (status, headers, body) -> expect body .to-match /^Test / expect body .to-match /\ test$/ diff --git a/src/application.ls b/src/application.ls index e4dfb40..e4447a2 100644 --- a/src/application.ls +++ b/src/application.ls @@ -1,6 +1,8 @@ -require! <[ bluebird ./cursor ./dom ./routes ./server-rendering ]> +require! <[ bluebird ./cursor ./dom ./routes ./server-rendering cookie ]> require! './virtual-dom-utils': 'dom-utils' +{keys, each, Obj} = require 'prelude-ls' + {span} = dom app-component = React.create-factory React.create-class do @@ -21,8 +23,8 @@ app-component = React.create-factory React.create-class do # FIXME make this user editable span "Page not found." -init-app-state = (initial-state, route-context) -> - cursor state: initial-state, route: route-context +init-app-state = (initial-state, route-context, cookies) -> + cursor state: initial-state, route: route-context, cookies: cookies observe-page-change = (root-tree, app-state) -> app-state.get \route .on-change (route) -> @@ -41,6 +43,9 @@ observe-page-change = (root-tree, app-state) -> window.scroll-to 0, 0 , 0 +parse-req-cookies = (cookies) -> + cookies |> Obj.map -> value: it + module.exports = # define an application instance create: (app) -> @@ -49,6 +54,7 @@ module.exports = start: -> route-set = app.routes! path = (location.pathname + location.search + location.hash) + client-cookies = parse-req-cookies cookie.parse(document.cookie) root-dom-node = document.get-element-by-id "application" @@ -60,7 +66,7 @@ module.exports = app-state = if server-state cursor server-state else - init-app-state app.get-initial-state!, routes.resolve(route-set, path) + init-app-state app.get-initial-state!, routes.resolve(route-set, pathname), client-cookies # Boot the app @@ -73,6 +79,15 @@ module.exports = # Whenever app state changes re-render + app-state.get 'cookies' .on-change (cookies) -> + cookies |> keys |> each (k) -> + c = if cookies[k] then cookies[k].value else cookies[k] + unless (client-cookies[k] && client-cookies[k].value === JSON.stringify c) + if (c is null or c is undefined) + document.cookie = cookie.serialize k, null, {expires: (new Date())} + else + document.cookie = cookie.serialize k, c, cookies[k].options + app-state.on-change -> root.set-state app-state: app-state # Set up SPA navigation @@ -82,15 +97,29 @@ module.exports = # render a particular route to string # returns a promise of [state, body] - render: (path) -> + render: (req, res) -> + path = req.original-url route-set = app.routes! - app-state = init-app-state app.get-initial-state!, routes.resolve(route-set, path) + client-cookies = parse-req-cookies req.cookies + app-state = init-app-state app.get-initial-state!, null, client-cookies transaction = app-state.start-transaction! + # send new cookies if they are modified during transaction + + app-state.get 'cookies' .on-change (cookies) -> + cookies |> keys |> each (k) -> + c = if cookies[k] then cookies[k].value else cookies[k] + unless (client-cookies[k] && client-cookies[k].value === JSON.stringify c) + if (c is null or c is undefined) + res.clear-cookie k + else + res.cookie k, c, cookies[k].options + # Boot the app app.start app-state + app-state.get 'route' .update -> routes.resolve(route-set, path) root-element = app-component app-state: app-state, routes: route-set app-state.end-transaction transaction @@ -100,20 +129,34 @@ module.exports = # process a form from a particular route and render to string # returns a promise of [state, body, location] - process-form: (path, post-data) -> + process-form: (req, res) -> + path = req.original-url + + client-cookies = parse-req-cookies req.cookies + route-set = app.routes! - app-state = init-app-state app.get-initial-state!, routes.resolve(route-set, path) + app-state = init-app-state app.get-initial-state!, null, client-cookies + + app-state.get 'cookies' .on-change (cookies) -> + cookies |> keys |> each (k) -> + c = if cookies[k] then cookies[k].value else cookies[k] + unless (client-cookies[k] && client-cookies[k].value === JSON.stringify c) + if (c is null or c is undefined) + res.clear-cookie k + else + res.cookie k, c, cookies[k].options transaction = app-state.start-transaction! # Boot the app app.start app-state + app-state.get 'route' .update -> routes.resolve(route-set, path) root-element = app-component app-state: app-state, routes: route-set # Process the form - location = server-rendering.process-form root-element, app-state, post-data, path + location = server-rendering.process-form root-element, app-state, req.body, path app-state.end-transaction transaction .then -> diff --git a/src/server-rendering.ls b/src/server-rendering.ls index b9f48c6..606bf9e 100644 --- a/src/server-rendering.ls +++ b/src/server-rendering.ls @@ -1,5 +1,5 @@ dom-utils = require './virtual-dom-utils' -{difference, filter, first, keys, Obj} = require 'prelude-ls' +{difference, filter, first, keys, Obj, each} = require 'prelude-ls' ReactServerRenderingTransaction = require 'react/lib/ReactServerRenderingTransaction' ReactDefaultBatchingStrategy = require 'react/lib/ReactDefaultBatchingStrategy' @@ -48,7 +48,7 @@ fake-event = (element, opts = {}) -> change-inputs = (inputs, post-data) -> inputs |> each -> - it.props.on-change (fake-event it, value: post-data[it.props.name]) + it.props.on-change (fake-event it, value: post-data[it.props.name]) if it.props.on-change ReactUpdates.flushBatchedUpdates! submit-form = (form) -> diff --git a/src/server.ls b/src/server.ls index 0fe9fb6..27bc3bf 100644 --- a/src/server.ls +++ b/src/server.ls @@ -1,4 +1,4 @@ -require! <[ express fs path jade bluebird body-parser ./bundler LiveScript babel/register ]> +require! <[ express fs path jade bluebird body-parser ./bundler LiveScript babel/register cookie-parser ]> {each, values, filter, find, flatten, map, first} = require 'prelude-ls' defaults = @@ -19,13 +19,13 @@ module.exports = (options) -> get = (req, res) -> console.log "GET", req.original-url - arch-get app, req.original-url, options + arch-get app, req, res, options .spread (status, headers, body) -> res.status status .set headers .send body post = (req, res) -> console.log "POST", req.original-url, req.body - arch-post app, req.original-url, req.body, options + arch-post app, req, res, options .spread (status, headers, body) -> res.status status .set headers .send body @@ -33,6 +33,7 @@ module.exports = (options) -> server = express! .use "/#{options.paths.public}", express.static path.join(options.paths.app.abs, options.paths.public) .use body-parser.urlencoded extended: false + .use cookie-parser! .get '*', get .post '*', post @@ -71,14 +72,14 @@ module.exports = (options) -> render: layout-render /* end-test-exports */ -arch-get = (app, url, options) -> - app.render url +arch-get = (app, req, res, options) -> + app.render req, res .spread (meta, app-state, body) -> html = layout-render meta, body, app-state, options [200, {}, html] -arch-post = (app, url, post-data, options) -> - app.process-form url, post-data +arch-post = (app, req, res, options) -> + app.process-form req, res .spread (meta, app-state, body, location) -> # FIXME build a full URL for location to comply with HTTP return [302, 'Location': location, ""] unless body diff --git a/src/virtual-dom-utils.ls b/src/virtual-dom-utils.ls index 87d32bf..752c43d 100644 --- a/src/virtual-dom-utils.ls +++ b/src/virtual-dom-utils.ls @@ -1,5 +1,7 @@ test-utils = React.addons.TestUtils +{filter, find, any} = require 'prelude-ls' + extract-route = (tree) -> routes = test-utils.find-all-in-rendered-tree tree, -> return it.get-layout-template and typeof! it.get-layout-template is 'Function'