├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc_images └── Card Game Click.png ├── public └── js │ ├── app.js │ └── page.js ├── shard.yml ├── spec ├── lattice-core_spec.cr └── spec_helper.cr └── src ├── lattice-core.cr └── lattice-core ├── application.cr ├── base62.cr ├── basic_user.cr ├── connected.cr ├── connected ├── connected_event.cr ├── event_handler.cr ├── object_list.cr ├── web_object.cr └── web_socket.cr ├── hotfixes ├── gzip_header.cr └── ssl_socket.cr ├── page.cr ├── public_storage.cr ├── ring_buffer.cr ├── templates └── page.slang ├── user.cr └── version.cr /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /.shards/ 4 | /doc/ 5 | # Libraries don't need dependency lock 6 | # Dependencies will be locked in application that uses them 7 | /shard.lock 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 0.16 2 | 3 | Events and messages are now handled much more cleanly. Much of WebSocket's documentation 4 | was removed since it was getting stale. Will rewrite as things stabilize. 5 | 6 | 7 | # Version 0.15 8 | 9 | More significant changes. 10 | 11 | ### Lattice::User 12 | 13 | This new class is now used to completely abstract sessions and sockets away from the app. This class has a timeout method that is called when a session expires, and handles a socket connection. The [a card game Player](https://github.com/jasonl99/card_game/blob/user_class/src/card_game/player.cr) is a good place to see how this works. 14 | 15 | ### Lattice::Connected 16 | 17 | Various changes to WebSocket, WebObject and events to remove references to sockets and sessions, and instead include the new User as part of the message. 18 | 19 | 20 | 21 | ### PLEASE NOTE 22 | 23 | There is a bug in the LLVM compiler version less than 3.8(?) that causes Digest::SHA1.digest("some string") to return data inconsistently when building an app with `--release`. This is used in lattice to create a signature for the dom_id. This means that a --release build will not correctly connect dom_ids. 24 | 25 | 26 | 27 | # Version 0.12 28 | 29 | ### Changes 30 | 31 | A significant change for better handling the DOM; an object can now be completely contained and return via `to_html`. Prior to this change, `WebObject` did not enclose itself with an html element. It now does so, with options to control the generated tag (i.e., modify classes, add a DOM id). For example, the card_game sample's `card_game.slang` previously did something like this to show the chat_room: 32 | 33 | ```slim 34 | div.chat_room data-item=chat_room.dom_id data-subscribe="" 35 | == chat_room.content 36 | ``` 37 | 38 | In retrospect, this seems like a glaring oversight. Now, `card_game.slang` only needs this: 39 | 40 | ```ruby 41 | == chat_room.to_html 42 | ``` 43 | 44 | With this change, it becomes a lot easier to have a container, and now there's no need for `StaticBuffer`(strings) and `DynamicBuffer`(WebObjects). The new [`ObjectList`](https://github.com/jasonl99/lattice-core/blob/cleaner_dom/src/lattice-core/connected/object_list.cr) handles both, and they can be intermixed. In fact, this makes it easier to do add _any_ class that can have advanced rendering capabilities without the extra overhead of WebObject. Consider a Counter class that just renders a...counter: 45 | 46 | ```ruby 47 | class Counter 48 | property value = 0 49 | def to_html( dom_id ) 50 | "#{@value}" 51 | end 52 | end 53 | ``` 54 | 55 | The index is already added to the passed dom_id from render_item. Subclassing ObjectList would be simple, and still allow other types: 56 | 57 | ```ruby 58 | class CounterList < ObjectList 59 | alias ListType = Counter | WebObject | String 60 | end 61 | ``` 62 | 63 | What makes this _especially_ crazy, is it's now possible to have nested containers, each rendering its own stuff. 64 | 65 | #### Javascript 66 | 67 | `app.js` had some changes required by this new methodology. Events and subscriptions are now added automatically when content changes through any WebObject. 68 | 69 | __However__, this is still a major work in progress. 70 | 71 | # Version 0.11 72 | 73 | ### Changes 74 | 75 | * Added a `RingBuffer(T)` to keep a fixed number of items in an object. For example, a chat_room might keep the last 100 ChatMessages. It could do this with `messages = RingBuffer(ChatMessage).new(max_items: 100).`, Items are available in first-in, first-out in `#values` 76 | * Changed behavior of `WebObject#dom_id` to create a more reliable id that can be extended more easily when searching for objects. The key piece is provided by `#signature` . 77 | * Added `WebObject#observer` and `WebObject#add_observer` to allow messaging between WebObjects. Observers are added to an object by calling `#add_observer` with the observing object. Any events that occur in the observered object are sent to the `observer#on_event` with a ConnectedMessage. 78 | * Added `Connected::EventObserver` which has a RingBuffer for events received. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Jason Landry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lattice-core 2 | 3 | A [crystal-lang](https://github.com/crystal-lang/crystal) framework built on [kemal](https://github.com/kemalcr/kemal) that takes a realtime-first approach to web development. 4 | 5 | ## Background 6 | 7 | What happens when you type in an address in your browser's address bar and hit enter? A lot, actually. A request gets built, a connection established, information is exchanged (for example, what monitor resolution do I have, what operating system am I using, what browser?) The server takes this request, maps it against known addresses, authenticates you, creates a response by querying a database or large-scale caching system, and sends it, along with a bunch of headers that tell your browser how to react and what to do with the data. It spits out an enormous amount of data to display the current _state_ of something. Moments later that state is wrong and needs to be updated. 8 | 9 | And for what purpose? Have you ever looked at the same page or two different computers? It's almost identical, with some customization for each user. Making matters worse, the web has always been a stateless system; once a web page is transferred, the server forgets you like a bad sandwich. 10 | 11 | We don't want that! When I look at a baseball scoreboard, I want to see the current score of each game, complete with ball & strike counts for the current at-bat. When I look at a stock price, I want to see the trade that occurred _just now_. When I look at a news story, I want to see a chart that shows how other people are reacting to the story. 12 | 13 | Applications that run on your phone or computer are interactive - they feel _immediate_. When you press a button, the screen changes _now_. 14 | 15 | Modern web development has gone a long way to making web sites more like local apps, but it's hard to do. It's a nightmare to keep track of which objects update, and if they are going to interfere with each other. It's also horribly inefficient -- every little update has a ton of overhead on both the server and browser. 16 | 17 | ### State 18 | 19 | A lot of this difficult comes about because we take something we created (a scoreboard page that shows the Red Sox beating the Yankees 3-0), and try to keep it in sync with the server. We resort to sleight-of-hand and create requests that run every 10 or 20 seconds and get the latest score. We try to keep each side's state the same. 20 | 21 | Think about that for a second: Let's say we have a thousand users looking at a scoreboard page, and our javascript code polls it every ten seconds for updates. That's 6,000 requests a minute for the simple act of seeing if any scores changed. Requests that the server has to look up (load from storage, render html). It's insanity, is what it is. 22 | 23 | At some point, our server learned that the Red Sox had taken a 4-0 lead. In the current system, those thousand users, spread out over the next ten seconds, will see the new score (many of them happy about it). But the barrage doesn't stop. It keeps coming, our now-annoyed server wondering why it has to keep telling all these users "IT'S STILL 4-0 LEAVE ME ALONE!" 24 | 25 | This happens for one reason: the http protocol was designed to just send a message and hang up. In the early days of the web, this worked fine, there really was no means to create dynamic content, so it didn't matter. Today, not so much. 26 | 27 | I happened to see a demo that showed how Kemal does a realtime chat and thought, "wow, that's actually a lot easier than I thought it would be." It was a little spark. What if an entire framework was written in the same way? What if the server didn't just hang up every time I requested something. 28 | 29 | ## Design Goals 30 | 31 | There are several design goals of lattice core. 32 | 33 | ### Server Persistence 34 | 35 | Every connected object, be it a scoreboard, individual score, individual player (in short, an instance of `WebObject`) remains instantiated on the server while there are users interested in it. Users are also remaining instantiated on the server. 36 | 37 | How do we know if there are users interested? They `subscribe` to an WebObject. This means that updates are sent to their browser. Not subscribed? You don't get those events. It also means that actions (as defined by the server) are sent back to a real object on the server. If two people click on the same game, a single `game_object` receives each event and can do its thing. 38 | 39 | ### Server Drives all Updates 40 | 41 | The way ajax (or other polling methods) work today, the browser decides if it is displaying the right data. It goes and asks the server if we need to update this score. It's backwards. But we now have a direct line to every browser, so the server itself can update the browser. 42 | 43 | ### Everything is Object Oriented 44 | 45 | Every `web_object` has a `#to_html` method, which creates the entire thing, element tags and all. Creating an element is done entirely within `WebObject`, and all subscriptions handled automatically, and there are plenty of helper methods to tweak the output. What makes this particularly appealing is you can simply create web_objects that contain other web_objects. 46 | 47 | For example, to create a scoreboard, it's as simple as this: 48 | 49 | ```ruby 50 | class LeagueScores < Lattice::Connected::WebObject 51 | end 52 | 53 | class Scoreboard < Lattice::Connected::WebObject 54 | property scores = [] of LeagueScores 55 | scores << LeagueScores("nhl") 56 | scores << LeagueScores("mlb") 57 | scores << LeagueScores("nba") 58 | scores << LeagueScores("nfl") 59 | 60 | def content 61 | scores.map(&.to_html).join 62 | end 63 | end 64 | ``` 65 | 66 | With that, you can now call `scoreboard.to_html` and have a ready-to-go chunk of html that will update in realtime. 67 | 68 | ## WebSockets 69 | 70 | This framework currently uses WebSockets as a message transport between client and server. There are exactly two places messages are exchanged (`WebSocket#on_message` and `WebSocket#send`, and they are simply strings. 71 | 72 | This means it'll be trivial to use other realtime, two-way technologies (http/2, webRTC, whatever's next) as it becomes available for Crystal. 73 | 74 | 75 | 76 | # Is This Thing Ready To Use For Anything? 77 | 78 | Yes, it's ready for experimentation, but that's about it. 79 | 80 | I also want to make clear that I'm not someone with a traditional programming pedigree - I don't have any sort of computer science degree, and I really shudder at the thought of having to deal with the traditional language that comes with that territory (UML diagrams and design patterns make me want to get back burying my head in neovim). But I've been doing this sort of thing for a pretty long time (I used an EPROM programmer to change my `Apple ][` into a `Jason ][`). But I've only been playing around with Crystal for just a couple of months. So please, if you see something that seems like a rookie mistake, be gentle. 81 | 82 | The API is not stable yet. There's no databse integration, no user management. Yet. 83 | 84 | But the goal is to have a real, useful, fast framework to develop truly immersive websites that are a joy to use and fun to code. 85 | 86 | # Show Me An Example 87 | 88 | Ok, take a look at [card_game](https://github.com/jasonl99/card_game). It is designedto illustrate the power of WebSockets in general, and this framework in particular. You can clone it, update the shards, and run it in a few lines. 89 | 90 | I'd also suggest you take a look at the [Lattice::Connected API](https://github.com/jasonl99/lattice-core/wiki/Lattice_Connected-API) wiki page. It attempts to illustrate the concepts in a conversational manner, as if we're sitting around talking about it. But it's also wildly changing still. 91 | 92 | There's two new demo apps: 93 | 94 | [calculator](https://github.com/jasonl99/calculator) - A shareable calculator (everyone can punch buttons). 95 | 96 | [md-live](https://github.com/jasonl99/md_live) - A demo that shows markdown rendered in real time as you type. And this can also be shared (collaborate on a doc). It's a demo, so don't expect to be able do much with it, but it illustrates the idea. It uses crystal's markdown library for rendering. 97 | 98 | ## Installation 99 | 100 | Add this to your application's `shard.yml`: 101 | 102 | ```yaml 103 | dependencies: 104 | lattice-core: 105 | github: jasonl99/lattice-core 106 | ``` 107 | 108 | ## Usage 109 | 110 | For now, I suggest you look at card_game. It will be maintained alongside lattice-core so as the API changes, so will card_game. 111 | 112 | ## Contributors 113 | 114 | - [Jason Landry](https://github.com/[your-github-name]) Jason Landry - creator, maintainer 115 | 116 | -------------------------------------------------------------------------------- /doc_images/Card Game Click.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonl99/lattice-core/129416dca6f3e6903fa41624a08e5f47ca9d9e98/doc_images/Card Game Click.png -------------------------------------------------------------------------------- /public/js/app.js: -------------------------------------------------------------------------------- 1 | socket_protocol = "ws:" 2 | if (location.protocol === 'https:') { socket_protocol = "wss:" } 3 | connected_object = new WebSocket(socket_protocol + location.host + "/connected_object"); 4 | connected_object.onmessage = function(evt) { handleSocketMessage(evt.data, evt) }; 5 | connected_object.onclose = function(evt) { console.log("Connected Socket closed", evt) } 6 | 7 | document.addEventListener("DOMContentLoaded", function(evt) { 8 | connected_object.onopen = function(evt) { 9 | console.log("Socket connecting, configuring for updates..") 10 | addSubscribers(document.querySelector("body"), self.target) 11 | connectEvents() 12 | }; 13 | }) 14 | 15 | 16 | function sendEvent(msg,socket) { 17 | socket.send(JSON.stringify(msg)) 18 | } 19 | 20 | function baseEvent(evt,event_action, action_params = {}) { 21 | // id = evt.target.getAttribute("data-item") 22 | // console.log(evt) 23 | msg = {} 24 | send_attribs = evt.target.getAttribute("data-event-attributes") 25 | if (!send_attribs) { send_attribs = "" } 26 | attribs = getItems(send_attribs) 27 | final_params = {} 28 | for (var i=0;i 35 | final_params[attrname] = action_params[attrname]; 36 | } 37 | id = evt.target.getAttribute("data-item") 38 | msg[id] = {action: event_action, params: final_params} 39 | return msg 40 | 41 | } 42 | // outgoing events look like this:// {"some-data-item": {action: "click", params: {x:123, y:232}}} 43 | // On the server, the key is parsed for a valid, instantiated connectedObject 44 | // that is subscribed to, and the action_parameters sent. 45 | function handleEvent(event_type, el, socket, options = {formReset: true}) { 46 | switch (event_type) { 47 | case "click": 48 | console.log("Handling click event", el) 49 | el.addEventListener("click", function(evt) { 50 | msg = baseEvent(evt,"click") 51 | sendEvent(msg,socket) 52 | console.log("Clicked!", msg) 53 | // socket.send(JSON.stringify(msg)) 54 | }) 55 | break; 56 | case "input": 57 | el.addEventListener("input", function(evt) { 58 | msg = baseEvent(evt,"input", {value: el.value}) 59 | sendEvent(msg,socket) 60 | // socket.send(JSON.stringify(msg)) 61 | }) 62 | break; 63 | case "mouseleave": 64 | el.addEventListener("mouseleave", function(evt) { 65 | msg = baseEvent(evt,"mouseleave") 66 | sendEvent(msg,socket) 67 | // socket.send(JSON.stringify(msg)) 68 | }) 69 | break; 70 | case "mouseenter": 71 | el.addEventListener("mouseenter", function(evt) { 72 | msg = baseEvent(evt,"mouseenter") 73 | sendEvent(msg,socket) 74 | // socket.send(JSON.stringify(msg)) 75 | }) 76 | case "submit": 77 | el.addEventListener("submit", function(evt) { 78 | evt.preventDefault(); 79 | evt.stopPropagation(); 80 | msg = baseEvent(evt, "submit", formToJSON(el)) 81 | // console.log("Submitting:", msg) 82 | sendEvent(msg,socket) 83 | // socket.send(JSON.stringify(msg)) 84 | if (options.formReset) { 85 | el.reset(); //TODO This is just a quick method of clearing the form for now 86 | } 87 | }) 88 | break; 89 | } 90 | } 91 | 92 | // given a form, this returns the data contained therein as 93 | // a JSON object, with keys the element names, and the values 94 | // the actual form values. 95 | function formToJSON(form) { 96 | return [].reduce.call(form.elements, (data, element) => { 97 | if (element.name && data) { 98 | data[element.name] = element.value; 99 | } 100 | return data; 101 | }, {}); 102 | } 103 | 104 | // given a string, return a trimmed array of items separated by commas 105 | function getItems(item_list) { 106 | return item_list.split(",").filter(function(e){return e.trim()}) 107 | } 108 | 109 | // given an element, set up javascript handlers for 110 | // each event type found. data-events is a comma-delimited 111 | // list of events that map loosely to native javascript 112 | // events recognized by addEventHandler, but we can also 113 | // define our own. 114 | // i.e. 115 | function handleElementEvents(el,socket) { 116 | event_types = getItems(el.getAttribute("data-events")); 117 | for (var i=0; i 0 && children.length > maxChildren) { 278 | el.removeChild(children[0]) 279 | } 280 | } 281 | connectEvents(el.lastChild) 282 | addSubscribers(el.lastChild) 283 | break; 284 | } 285 | // el.closest("[data-version]").setAttribute("data-version",domData.version) 286 | } 287 | } else { 288 | console.log("cound not locate element " + domData.id) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /public/js/page.js: -------------------------------------------------------------------------------- 1 | // Now part of app.js 2 | // socket_protocol = "ws:" 3 | // if (location.protocol === 'https:') { socket_protocol = "wss:" } 4 | // connected_object = new WebSocket(socket_protocol + location.host + "/connected_object"); 5 | // connected_object.onmessage = function(evt) { handleSocketMessage(evt.data, evt) }; 6 | // connected_object.onclose = function(evt) { console.log("Connected Socket closed", evt) } 7 | // document.addEventListener("DOMContentLoaded", function(evt) { 8 | // connected_object.onopen = function(evt) { 9 | // console.log("Socket connecting, configuring for updates..") 10 | // addSubscribers(document.querySelector("body"), self.target) 11 | // connectEvents() 12 | // }; 13 | // }) 14 | 15 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: lattice-core 2 | version: 0.12.0 3 | 4 | dependencies: 5 | slang: 6 | github: jeromegn/slang 7 | branch: master 8 | kemal: 9 | github: kemalcr/kemal 10 | kemal-session: 11 | github: jasonl99/kemal-session 12 | branch: master 13 | # branch: session_timeout 14 | # branch: master 15 | # path: /home/jason/crystal/kemal-session 16 | baked_file_system: 17 | github: schovi/baked_file_system 18 | 19 | authors: 20 | - Jason Landry 21 | 22 | crystal: 0.20.5 23 | 24 | license: MIT 25 | -------------------------------------------------------------------------------- /spec/lattice-core_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Lattice::Core do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/lattice-core" 3 | -------------------------------------------------------------------------------- /src/lattice-core.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "kemal-session" 3 | require "baked_file_system" 4 | require "colorize" 5 | require "logger" 6 | require "kilt/slang" 7 | require "./lattice-core/*" 8 | 9 | {% if Crystal::VERSION == "0.21.0" %} 10 | puts "Crystal version: #{Crystal::VERSION}" 11 | require "./lattice-core/hotfixes/gzip_header" 12 | {% end %} 13 | 14 | # Session.destroy(id) do 15 | # puts "Session #{id} is about to be destroyed!".colorize(:red).on(:white) 16 | # expired = Lattice::Connected::WebSocket::REGISTERED_SESSIONS.find do |socket,session_id| 17 | # session_id == id 18 | # end 19 | # if expired 20 | # puts "Found session's socket, removing from REGISTERED_SESSIONS".colorize(:blue).on(:white) 21 | # Lattice::Connected::WebSocket::REGISTERED_SESSIONS.delete expired.first 22 | # end 23 | # end 24 | 25 | # class Session 26 | # def self.before_destroy(id : String) 27 | # puts "Session #{id} is about to be destroyed!".colorize(:red).on(:white) 28 | # expired = Lattice::Connected::WebSocket::REGISTERED_SESSIONS.find do |socket,session_id| 29 | # session_id == id 30 | # end 31 | # if expired 32 | # puts "Found session's socket, removing from REGISTERED_SESSIONS".colorize(:blue).on(:white) 33 | # Lattice::Connected::WebSocket::REGISTERED_SESSIONS.delete expired.first 34 | # end 35 | # end 36 | # end 37 | 38 | module Lattice::Core 39 | end 40 | -------------------------------------------------------------------------------- /src/lattice-core/application.cr: -------------------------------------------------------------------------------- 1 | require "./public_storage" # this is required early so the files are loaded. 2 | 3 | module Lattice::Core 4 | class Application 5 | 6 | @@socket_path : String? 7 | 8 | # create a kemal route for every file in PublicStorage 9 | PublicStorage.files.each do |file| 10 | get file.path do |context| 11 | context.response.content_type = file.mime_type 12 | file.read 13 | end 14 | end 15 | 16 | def self.route_socket(user_class = Lattice::BasicUser, path = "/connected_object" ) 17 | @@socket_path = path 18 | ws(path) do |socket, ctx| 19 | session = Session.new(ctx) 20 | session.string("init","true") 21 | user = user_class.find_or_create(session.id) 22 | user.socket = socket unless user.socket 23 | 24 | socket.on_message do |message| 25 | Lattice::Connected::WebSocket.on_message(message, socket, user) 26 | end 27 | 28 | socket.on_close do 29 | # Pass on notification of socket so we can handle it 30 | puts "Application route_socket socket.on_close called" 31 | Lattice::Connected::WebSocket.on_close(socket, user) 32 | end 33 | 34 | end 35 | end 36 | 37 | def self.run 38 | route_socket unless @@socket_path # set up the default socket path 39 | puts "Running kemal" 40 | Kemal.run 41 | end 42 | 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/lattice-core/base62.cr: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | class Base62 3 | 4 | class ArgumentError < Exception; end 5 | ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 6 | BASE = ALPHABET.size.to_u64 7 | 8 | # Creates a base62 digest of a given string. 9 | # ``` 10 | # string_digest = Base62.string_digest("Hi Bob") # "a8t1hFHyM"` 11 | # string_digest = Base62.string_digest("Hi Bob") # 2213222356800000 12 | # 13 | def self.string_digest( target : String) : String 14 | sha_digest = Digest::SHA1.digest target 15 | sd = shorten_digest(sha_digest) 16 | encode sd 17 | end 18 | 19 | # returns the UInt64 equivalent 20 | def self.int_digest( target : String) : UInt64 21 | sha_digest = Digest::SHA1.digest target 22 | sd = shorten_digest(sha_digest) 23 | return sd 24 | end 25 | 26 | 27 | def self.shorten_digest( digest : StaticArray(UInt8,20)) 28 | values = digest.first(8).map_with_index do | unit, index | 29 | index == 0 ? unit : (BASE ** index) * unit 30 | end 31 | values.sum 32 | end 33 | 34 | def self.encode( big_int : UInt64) : String 35 | multiples = [] of UInt32 36 | while (big_int > BASE) 37 | multiples << (big_int % BASE).to_u32 38 | big_int = (big_int / BASE ) 39 | end 40 | multiples << big_int.to_u32 41 | multiples.reverse.map {|at_pos| ALPHABET[at_pos]}.join("").as(String) 42 | end 43 | 44 | def self.decode ( encoded : String) 45 | multiples = encoded.split("").map {|char| ALPHABET.index(char).as(Int32).to_u64}.reverse 46 | values = multiples.map_with_index do | unit, index | 47 | index == 0 ? unit : (BASE ** index) * unit 48 | end 49 | values.sum 50 | end 51 | 52 | 53 | end 54 | 55 | -------------------------------------------------------------------------------- /src/lattice-core/basic_user.cr: -------------------------------------------------------------------------------- 1 | require "./user" 2 | module Lattice 3 | 4 | class BasicUser < User 5 | 6 | def load 7 | puts "Load user data here or override" 8 | end 9 | 10 | def save 11 | puts "Save user data here or override" 12 | end 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /src/lattice-core/connected.cr: -------------------------------------------------------------------------------- 1 | require "./connected/*" 2 | module Lattice 3 | module Connected 4 | # alias ConnectedMessage = Hash( String, JSON::Type ) | Message 5 | SOCKET_LOGGER = Logger.new(File.open("./connected.log","a")) 6 | SOCKET_LOGGER.level = Logger::WARN 7 | SOCKET_LOGGER.formatter = Logger::Formatter.new do |severity, datetime, progname, message, io| 8 | # io << severity[0] << ", [" << datetime << " #" << Process.pid << "] " 9 | # io << severity.rjust(5) << " -- " << progname << ": " << message 10 | io << message 11 | end 12 | 13 | # used for logging 14 | def self.shorten_socket(socket) 15 | "socket_#{socket.object_id.to_s[-3..-1]}" 16 | end 17 | 18 | # used for logging 19 | def self.shorten_session(session_id) 20 | "session_#{session_id[-3..-1]}" 21 | end 22 | 23 | def self.log(indicator, message, level = :default) 24 | colorized_indicator = 25 | case indicator 26 | when :in 27 | "data in".colorize(:red).on(:white) 28 | when :out 29 | "data out".colorize(:green).on(:white) 30 | when :process 31 | "process ".colorize(:light_gray).on(:dark_gray) 32 | when :validate 33 | "validate".colorize(:light_gray).on(:dark_gray) 34 | else 35 | "UNKNOWN".colorize(:white).on(:red) 36 | end 37 | Lattice::Connected::SOCKET_LOGGER.info "#{colorized_indicator} #{message}" 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/lattice-core/connected/connected_event.cr: -------------------------------------------------------------------------------- 1 | require "./web_object" 2 | module Lattice 3 | module Connected 4 | 5 | # Messages are outgoing hashes. Start restrtively, and expand as needed. 6 | #{"a"=>"b", x={"y"=>"z"}} 7 | alias Message = Hash(String,String | Hash(String,String)) 8 | alias UserMessage = Hash(String,JSON::Type) 9 | 10 | abstract class Event 11 | property created = Time.now 12 | def debug(str) 13 | puts "#{self.class}: #{str}".colorize(:blue).on(:white) 14 | end 15 | end 16 | 17 | 18 | # A UserEvent is the first interaction we have with some action by the user. This is where 19 | # message validity is tested would be a good place for authenticating actions. The 20 | # sole entry point for action is @input, which is simply a string. 21 | # that string must become a valid JSON object, or the input is considered an error. 22 | # If the input is valid, the chain continues and a new IncomingEvent is created 23 | class UserEvent < Event 24 | property user : Lattice::User 25 | property input : String 26 | property? message : UserMessage? 27 | property? error : Message? 28 | 29 | def initialize(@input, @user) 30 | user.session.int("random", rand(10000)) 31 | begin 32 | @message = JSON.parse(@input).as_h 33 | debug "UserEvent #{@message} created with #{@input} for #{@user}" 34 | rescue 35 | error = Message.new 36 | error["error"] = "could not convert incoming message to JSON" 37 | error["source"] = @input[0..200] # limit the amount we capture for now 38 | error["user"] = @user.to_s 39 | @error = error 40 | debug "Error creating UserEvent: #{@error}" 41 | end 42 | incoming_event if valid? 43 | end 44 | 45 | def valid? 46 | !@error 47 | end 48 | 49 | def incoming_event 50 | if (message = @message) 51 | data = message.values.first.as(UserMessage) 52 | action = data["action"].as(String) 53 | params = data["params"].as(UserMessage) 54 | IncomingEvent.new( 55 | user: @user, 56 | dom_item: message.keys.first, 57 | action: action, 58 | params: params 59 | ) 60 | end 61 | end 62 | 63 | end 64 | 65 | # An IncomingEvent is data from an external source (currently only a User), which has 66 | # been validated format and possibly authentication. This step is further refined 67 | # where we now now the action, the dom_item that created the event, and the parameters. 68 | # furthermore, we know the dom_item, and can use this to look up an actual instantiated object 69 | class IncomingEvent < Event 70 | property user : Lattice::User 71 | property action : String? 72 | property params : UserMessage 73 | property component : String? 74 | property index : Int32? 75 | property dom_item : String 76 | 77 | #OPTIMIZE 78 | # create ClickEvent, InputEvent where 79 | # params are further refined 80 | def initialize(@user, @dom_item, @action, @params) 81 | 82 | debug "New IncomingEvent #{@dom_item} #{@action} #{@params} refers to #{web_object}" 83 | if (target = web_object) 84 | @component = target.component_id(@dom_item) 85 | @index = target.component_index(@dom_item) 86 | target.handle_event(self) 87 | end 88 | end 89 | 90 | # returns the instantiated web object for this item 91 | # if there is no object, this will return nil (the dom_id has basically expired) 92 | def web_object 93 | if (dom = @dom_item) 94 | WebObject.from_dom_id(dom) 95 | end 96 | end 97 | 98 | end 99 | 100 | class OutgoingEvent < Event 101 | 102 | # can't figure out how to cast to Message (Hash(String,JSON::Type) from Hash(String,Hash(String,String)) 103 | property message : Message | Hash(String, Hash(String, String)) 104 | property sockets : Array(HTTP::WebSocket) 105 | property source : WebObject 106 | 107 | def initialize(@message, @sockets, @source) 108 | debug "new OutgoingEvent: #{@message} for #{sockets.size} sockets sending to handler" 109 | send 110 | end 111 | 112 | def send 113 | source.class.event_handler.send_event(self) 114 | end 115 | end 116 | 117 | class InternalEvent < Event 118 | end 119 | 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /src/lattice-core/connected/event_handler.cr: -------------------------------------------------------------------------------- 1 | module Lattice 2 | module Connected 3 | class EventHandler 4 | 5 | 6 | def send_event(event : OutgoingEvent) 7 | puts "Sending #{event.message} to #{event.sockets.size} sockets" 8 | WebSocket.send event.sockets, event.message.to_json 9 | event.source.observers.select {|o| o.is_a?(WebObject)}.each &.observe_event(event, event.source) 10 | event.source.class.observers.select {|o| o.is_a?(WebObject)}.each &.as(WebObject).observe_event(event, event.source) 11 | end 12 | 13 | def handle_event(event : IncomingEvent, target : WebObject) 14 | target.on_event event 15 | if (prop_tgt = target.propagate_event_to?) 16 | prop_tgt.on_event event 17 | end 18 | target.observers.select {|o| o.responds_to?(:observe_event)}.each &.observe_event(event, target) 19 | #target.class.observers.select {|o| o.responds_to?(:observe_event)}.each &.as(WebObject).observe_event(event, target) 20 | target.class.observers.each &.observe_event(event, target) 21 | end 22 | 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /src/lattice-core/connected/object_list.cr: -------------------------------------------------------------------------------- 1 | module Lattice 2 | module Connected 3 | class ObjectList < WebObject 4 | alias ListType = WebObject | String 5 | @max_items : Int32 = 25 6 | property max_items : Int32 = 25 7 | property items : RingBuffer(ListType) 8 | property items_dom_id : String? # need for updating clients wth #insert 9 | 10 | def initialize(@name, @creator : WebObject? = nil, max_items = @max_items) 11 | # def initialize(@name, @creator : webobject? = nil, max_items = 25) 12 | @items = RingBuffer(ListType).new(size: max_items) 13 | @element_options["type"] = "div" 14 | @element_options["class"] = "object-list" 15 | super(@name, @creator) 16 | end 17 | 18 | def subscribed( user : Lattice::User ) 19 | if (socket = user.socket) 20 | subscribed(socket) 21 | end 22 | end 23 | 24 | def subscribed(socket) 25 | puts "ObjectList (#{self.name}) subscribed sending max-children #{@max_items}" 26 | send_max = {"id"=>items_dom_id || dom_id,"attribute"=>"data-max-children","value"=>@max_items.to_s} 27 | puts "SUBSCRIBED #{send_max.class}: #{send_max}" 28 | self.as(WebObject).update_attribute(send_max, [socket]) 29 | end 30 | 31 | def add_content(new_content : ListType, update_sockets = true) 32 | @items << new_content 33 | # FIXME removed while testing standalone 34 | insert({"id"=>(items_dom_id || dom_id).as(String), "value"=>render_item new_content, @items.values.size + 1}) if update_sockets 35 | end 36 | 37 | def content 38 | item_content 39 | end 40 | 41 | def item_content : String 42 | @items.values.map_with_index {|obj, index| render_item obj, index}.join 43 | end 44 | 45 | def render_item( index : Int32) 46 | render_item( @items.values[index], index) 47 | end 48 | 49 | # in order of preference & flexibility, we find a way to render this object. 50 | def render_item(obj, index) 51 | val = nil 52 | case 53 | when !val && obj.responds_to?(:to_html) 54 | val = obj.to_html( dom_id: "#{dom_id("item")}:#{index}" ) 55 | when !val && obj.responds_to?(:content) 56 | val = obj.content 57 | else 58 | val = obj.to_s 59 | end 60 | val.as(String) 61 | end 62 | 63 | end 64 | 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /src/lattice-core/connected/web_object.cr: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | require "./connected_event" 3 | 4 | module Lattice 5 | module Connected 6 | 7 | class TooManyInstances < Exception 8 | end 9 | 10 | #TODO events need to abstract away session and web_socket stuff into Lattice::User 11 | abstract class WebObject 12 | # OPTIMIZE it would be better to have a #self.all_instances that goes through @@instances of subclasses 13 | INSTANCES = Hash(UInt64, self).new # all instance, with key as signature 14 | @signature : String? 15 | @@instances = Hash(String, UInt64).new # ("game1"=>12519823982) int is Base62.int_digest of signature 16 | @@observers = [] of WebObject 17 | @@event_handler = EventHandler.new 18 | @@max_instances = 1000 # raise an exception if this is exceeded. 19 | class_getter observers, instances, observer, emitter, event_handler 20 | 21 | @subscribers = [] of HTTP::WebSocket 22 | @observers = [] of self 23 | @components = {} of String=>String 24 | @content : String? # used as default content; useful for external content updates data. 25 | @element_options = {} of String=>String 26 | property index = 0 # used when this object is a member of Container(T) subclass 27 | property version = Int64.new(0) 28 | property creator : WebObject? 29 | property subscribers # Each {} of String=>String 30 | property observers # we talk to objects who want to listen by sending a listen_to messsage 31 | property name : String 32 | property auto_add_content = true # any data-item that is subscribed gets #content on subscribion 33 | property? propagate_event_to : WebObject? 34 | 35 | def initialize(@name : String, @creator : WebObject? = nil) 36 | if (creator = @creator) 37 | creator_string = "#{creator.class} '#{creator.name}'" 38 | @propagate_event_to = creator 39 | end 40 | check_instance_memory! 41 | self.class.add_instance self 42 | after_initialize 43 | end 44 | 45 | def self.find(name) 46 | if (signature = @@instances[name]?) 47 | INSTANCES[signature] 48 | end 49 | end 50 | 51 | def self.find_or_create(name, creator : WebObject? = nil) 52 | find(name) || new(name, creator) 53 | end 54 | 55 | def self.from_dom_id!(dom : String) : self 56 | from_dom_id(dom).as(self) 57 | end 58 | 59 | 60 | def handle_event( incoming : IncomingEvent ) 61 | if incoming.action == "subscribe" && (sock = incoming.user.socket) && (sess = incoming.user.session.id) 62 | puts "Subscribing to #{self}".colorize(:red).on(:white) 63 | subscribe(sock, sess) # this needs to be worked on. Not sure this is the right place for subs 64 | else 65 | end 66 | @@event_handler.handle_event(incoming, self) 67 | end 68 | 69 | def on_event( event : IncomingEvent) 70 | # puts "#{self.to_s} IncomingEvent action (#{event.component} #{event.action} #{event.params}".colorize(:green).on(:white) 71 | end 72 | 73 | def observe_event( event : IncomingEvent | OutgoingEvent, target) 74 | # puts "#{self.to_s}#observe_event : #{event}".colorize(:green).on(:white) 75 | end 76 | 77 | def self.observe_event( event : IncomingEvent | OutgoingEvent, target) 78 | # puts "#{self.to_s}.class#observe_event : #{event}".colorize(:green).on(:white) 79 | end 80 | 81 | def check_instance_memory! 82 | #TODO try garbage collecting first, and only then raise this erro. 83 | #gc would look for the first instance that has no subscribers, 84 | #call some on_=close method (so it could clean itself up, write to db, etc) 85 | #and then allow the instance to be created. 86 | if @@instances.size >= @@max_instances 87 | raise TooManyInstances.new("#{self.class} exceeds the maximum of #{@@max_instances}.") 88 | end 89 | end 90 | 91 | def after_initialize 92 | end 93 | 94 | def simple_class 95 | self.class.simple_class 96 | end 97 | 98 | def self.simple_class 99 | self.to_s.to_s.split("::").last 100 | end 101 | 102 | # get the index from the parent 103 | def self.child_of(creator : WebObject, name : String) 104 | obj = new(name: name ) 105 | obj.creator = creator 106 | obj.index = creator.as(Container).next_index 107 | obj 108 | end 109 | 110 | def self.instance(signature : String) 111 | INSTANCES[Base62.int_digest signature]? 112 | end 113 | 114 | # keep track of all instances, both at the class level (each subclass) and the 115 | # abstract class level. 116 | def self.add_instance( instance : WebObject) 117 | base62_digest = Base62.int_digest(instance.signature) 118 | INSTANCES[base62_digest] = instance 119 | @@instances[instance.name] = base62_digest 120 | end 121 | 122 | # Use Base62.string_digest 123 | # If this is a stored object (a databsae record, for example) the name 124 | # should represent that ("order-1021-jun2 155"). The idea is that a replicatable 125 | # piece of info, digested, is tough to duplicate unless you have the original pieces 126 | # that created it. 127 | def signature : String 128 | @signature ||= Base62.string_digest "#{self.class}#{self.name}" 129 | end 130 | 131 | # simple debugging catch early on if we are forgetting to clean up after ourselves. 132 | def self.display_all 133 | "There are a total of #{INSTANCES.size} WebObjects with a total of #{INSTANCES.values.flat_map(&.subscribers).size} subscribers" 134 | end 135 | 136 | def to_html( dom_id : String? = nil) 137 | open_tag(dom_id) + 138 | content + 139 | close_tag 140 | end 141 | 142 | def content 143 | "

Content for #{self.class} #{name} goes in #content " 144 | end 145 | 146 | # useful for 147 | def add_element_class( class_name) 148 | el_class = @element_options["class"]? 149 | unless el_class && el_class.split(" ").includes? class_name 150 | @element_options["class"] = "#{el_class} #{class_name}".lstrip 151 | end 152 | end 153 | 154 | # a header that contains this object and holds its dom_item 155 | def open_tag(rendered_dom_id : String? = nil) 156 | tag = @element_options.fetch("type", "div") 157 | # three options for dom id, selected in priority order 158 | data_item_id = rendered_dom_id || @element_options["data-item"]? || dom_id 159 | tag_string = "<#{tag} data-item='#{data_item_id}' " 160 | @element_options.reject {|opt,val| opt == "type"}.each do |(opt,val)| 161 | # puts opt, val 162 | tag_string += "#{opt}='#{val}' " 163 | end 164 | tag_string += ">\n" 165 | tag_string 166 | end 167 | 168 | def close_tag 169 | "" 170 | end 171 | 172 | def get_data # added for GlobalStats 173 | end 174 | 175 | # useful for logging, etc 176 | def to_s 177 | "#{self.class} #{self.name} (#{dom_id})" 178 | end 179 | 180 | # either the session & the value exist or its nil 181 | def session_string( session_id : String, value_of : String) 182 | if (session = Session.get(session_id)) && (value = session.string?(value_of) ) 183 | return value 184 | end 185 | end 186 | 187 | # send a message to given sockets 188 | def send(msg : Message, sockets : Array(HTTP::WebSocket)) 189 | 190 | puts "Sending class #{msg.class}".colorize(:red).on(:white) 191 | OutgoingEvent.new( 192 | message: msg, 193 | sockets: sockets, 194 | source: self 195 | ) 196 | end 197 | 198 | def refresh 199 | update({"id"=>dom_id, "value"=>content}, subscribers) 200 | end 201 | 202 | def update_content( content : String, subscribers = self.subscribers) 203 | return if @content == content 204 | @content = content 205 | update({"id"=>dom_id, "value"=>content}, subscribers) 206 | end 207 | 208 | def update_component( component : String, value : _ , subscribers = self.subscribers) 209 | if !@components[component]? || @components[component] != value.to_s 210 | @components[component] = value.to_s 211 | update({"id"=>dom_id(component), "value"=>value.to_s}, subscribers) 212 | end 213 | end 214 | 215 | def add_class( html_class : String ) 216 | add_class({"value"=>html_class}) 217 | end 218 | 219 | def remove_class( html_class : String ) 220 | remove_class({"value"=>html_class}) 221 | end 222 | 223 | #----------------------------------------------------------------------------------------- 224 | # these go out to the sockets and would have a javascript handler on the users' browser 225 | def remove_class( change : Hash(String,String), subscribers : Array(HTTP::WebSocket) = self.subscribers ) 226 | # try merging in other direction to eliminate needing the id 227 | msg = { "dom"=>{"id"=>dom_id,"action"=>"remove_class"}.merge(change) } 228 | send msg, subscribers 229 | end 230 | 231 | def add_class( change : Hash(String,String), subscribers : Array(HTTP::WebSocket) = self.subscribers ) 232 | # msg = { "dom"=>change.merge({"action"=>"add_class"}) } 233 | msg = { "dom"=>{"id"=>dom_id,"action"=>"add_class"}.merge(change) } 234 | send msg, subscribers 235 | end 236 | 237 | def update_attribute( change, subscribers : Array(HTTP::WebSocket) = self.subscribers ) 238 | msg = { "dom"=>change.merge({"action"=>"update_attribute"}) } 239 | send msg, subscribers 240 | end 241 | 242 | def update( change, subscribers : Array(HTTP::WebSocket) = self.subscribers ) 243 | msg = { "dom"=>change.merge({"action"=>"update"}) } 244 | send msg, subscribers 245 | end 246 | 247 | def append_value( change, subscribers : Array(HTTP::WebSocket) = self.subscribers ) 248 | msg = { "dom"=>change.merge({"action"=>"append_value"}) } 249 | send msg, subscribers 250 | end 251 | 252 | def value( change, subscribers : Array(HTTP::WebSocket) = self.subscribers ) 253 | msg = { "dom"=>change.merge({"action"=>"value"}) } 254 | send msg, subscribers 255 | end 256 | 257 | def act( action , subscribers : Array(HTTP::WebSocket) = self.subscribers ) 258 | msg = {"act" => action} 259 | send msg, subscribers 260 | end 261 | 262 | def insert( change, subscribers : Array(HTTP::WebSocket) = self.subscribers ) 263 | msg = { "dom"=>change.merge({"action"=>"insert"}) } 264 | send msg, subscribers 265 | end 266 | #----------------------------------------------------------------------------------------- 267 | 268 | # Converts a dom-style id and extracts that last number from it 269 | # for example, "card-3" returns 3. 270 | def index_from( source : String, max = 100 ) 271 | id = source.split("-").last.try &.to_i32 272 | id if id && id <= max && id >= 0 273 | end 274 | 275 | 276 | # if you're a really popular object, other objects want to hear what you have to say. This 277 | # gives those object a change to register their interest. Any observer gets a notification 278 | # when an event occurs on a listened-to object 279 | def add_observer( observer : WebObject) 280 | @observers << observer unless @observers.includes? observer 281 | end 282 | 283 | # a class observer is a little different, it just listens to events but has 284 | # no rendering capability of its own. It would be a composite object that 285 | # would handle this (in other words, an observer would have a @something WebObject 286 | # to display what is observed 287 | def self.add_observer( observer : WebObject ) 288 | @@observers << observer 289 | end 290 | 291 | def propagate(event, to = @propagate_event_to) 292 | if event && to 293 | to.on_event(event, self) 294 | end 295 | end 296 | 297 | # subscribers are sockets. This sets one endpoint at a WebObjec tinstance , while 298 | # the other end of the endpoint is the user's browser. Since each browser does it, it's 299 | # a one-to-many relationship (one server object to many browser sockets). 300 | # TODO a User should be subscribing. 301 | def subscribe( socket : HTTP::WebSocket , session_id : String?) 302 | unless subscribers.includes? socket 303 | subscribers << socket 304 | # notify of a user subscribption first, but then of a socket/session 305 | # if user not found 306 | if session_id && (user = User.find?(session_id) ) 307 | subscribed(user) 308 | else 309 | subscribed session_id, socket if session_id 310 | end 311 | # update({"id"=>dom_id, "value"=>content}, [socket]) if auto_add_content 312 | else 313 | # if things are working correctly, we shouldn't ever see this. 314 | end 315 | end 316 | 317 | # this session and socket are now subscribed to this object 318 | def subscribed( session_id : String, socket : HTTP::WebSocket) 319 | end 320 | 321 | def subscribed( user : Lattice::User ) 322 | end 323 | 324 | # tests if a socket is subscribed 325 | def subscribed?( socket : HTTP::WebSocket) 326 | subscribers.includes? socket 327 | end 328 | 329 | # delete a subscription for _socket_ 330 | def unsubscribe( socket : HTTP::WebSocket) 331 | @subscribers.delete(socket) 332 | unsubscribed socket 333 | end 334 | 335 | # this socket is now unsubscribed from this object 336 | def unsubscribed( socket : HTTP::WebSocket) 337 | end 338 | 339 | def dom_id( component : String? = nil ) : String 340 | if component 341 | @components[component] = "" unless @components[component]? 342 | component = "-#{component}" 343 | end 344 | "#{simple_class}-#{signature}#{component}" 345 | end 346 | 347 | # given a full dom_id that contains this object, this strips 348 | # the dom_id and returns just the component portion, which is 349 | # the key for @components 350 | # this assumes there is a "-" between the dom_id and the component 351 | def component_id( val : String) 352 | if val.starts_with?(dom_id) && val.size > dom_id.size + 1 353 | val[dom_id.size+1..-1] 354 | end 355 | end 356 | 357 | # a component_id can have a -number as an internal index 358 | # within WebObject. 359 | def component_index (val : String?) 360 | return unless val # obviously there's no idx 361 | val.split("-").last.to_i32? 362 | end 363 | 364 | # given a dom_id, attempt to figure out if it is already instantiated 365 | # as k/v in INSTANCES, or instantiate it if possible. 366 | # TODO it is not currently creating, not sure if that's possible with 367 | # signature? 368 | def self.from_dom_id( dom : String) 369 | if (split = dom.split("-") ).size >= 2 370 | klass, signature = dom.split("-").first(2) 371 | # for objects that stay instantiated on the server (objects that are being used 372 | # by multiple people or that require frequent updates) the default is to use 373 | # the classname-signature as a dom_id. The signature is something that is sufficiently 374 | # random that we can quickly determine if an object is "real". 375 | if (obj = from_signature(signature)) 376 | return obj if obj.class.to_s.split("::").last == klass 377 | end 378 | end 379 | end 380 | 381 | def self.from_signature( signature : String) 382 | base62_signature = Base62.int_digest(signature) 383 | if ( instance = INSTANCES[base62_signature]? ) 384 | return instance 385 | end 386 | end 387 | 388 | def self.subclasses 389 | {{@type.all_subclasses}} 390 | end 391 | 392 | end 393 | 394 | 395 | end 396 | end 397 | -------------------------------------------------------------------------------- /src/lattice-core/connected/web_socket.cr: -------------------------------------------------------------------------------- 1 | module Lattice::Connected 2 | 3 | class TooManySessions < Exception; end 4 | class UserException < Exception; end 5 | 6 | abstract class WebSocket 7 | 8 | @@max_sessions = 100_000 # I have absolutely no idea what this number and or should be. 9 | 10 | def self.close(socket) 11 | socket.close 12 | WebObject::INSTANCES.values.each do |web_object| 13 | web_object.unsubscribe(socket) 14 | end 15 | User.socket_closing(socket) 16 | end 17 | 18 | def self.send(sockets : Array(HTTP::WebSocket), msg) 19 | sockets.each do |socket| 20 | socket.send(msg) unless socket.closed? 21 | end 22 | end 23 | 24 | def self.on_message(message : String, socket : HTTP::WebSocket, user : Lattice::User) 25 | UserEvent.new(message, user) 26 | end 27 | 28 | def self.on_close(socket : HTTP::WebSocket, user : Lattice::User) 29 | WebObject::INSTANCES.values.each do |web_object| 30 | web_object.unsubscribe(socket) 31 | end 32 | User.socket_closing(socket) 33 | end 34 | 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /src/lattice-core/hotfixes/gzip_header.cr: -------------------------------------------------------------------------------- 1 | # A header in a gzip stream. 2 | class Gzip::Header 3 | def initialize(first_byte : UInt8, io : IO) 4 | header = uninitialized UInt8[10] 5 | header[0] = first_byte 6 | io.read_fully(header.to_slice + 1) 7 | 8 | if header[0] != ID1 || header[1] != ID2 || header[2] != DEFLATE 9 | raise Error.new("Invalid gzip header") 10 | end 11 | 12 | flg = Flg.new(header[3]) 13 | 14 | seconds = IO::ByteFormat::LittleEndian.decode(Int32, header.to_slice[4, 4]) 15 | @modification_time = Time.epoch(seconds).to_local 16 | 17 | xfl = header[8] 18 | @os = header[9] 19 | 20 | if flg.extra? 21 | xlen = io.read_byte.not_nil! 22 | @extra = Bytes.new(xlen) 23 | io.read_fully(@extra) 24 | else 25 | @extra = Bytes.empty 26 | end 27 | 28 | if flg.name? 29 | @name = io.gets('\0', chomp: true) 30 | end 31 | 32 | if flg.comment? 33 | @comment = io.gets('\0', chomp: true) 34 | end 35 | 36 | if flg.hcrc? 37 | crc16 = io.read_bytes(UInt16, IO::ByteFormat::LittleEndian) 38 | # TODO check crc16 39 | end 40 | end 41 | 42 | # :nodoc: 43 | def to_io(io) 44 | # header 45 | io.write_byte ID1 46 | io.write_byte ID2 47 | 48 | # compression method 49 | io.write_byte DEFLATE 50 | 51 | # flg 52 | flg = Flg::None 53 | flg |= Flg::EXTRA if @extra && @extra.size > 0 54 | flg |= Flg::NAME if @name 55 | flg |= Flg::COMMENT if @comment 56 | io.write_byte flg.value 57 | 58 | # time 59 | io.write_bytes(modification_time.epoch.to_u32, IO::ByteFormat::LittleEndian) 60 | 61 | # xfl 62 | io.write_byte 0_u8 63 | 64 | # os 65 | io.write_byte os 66 | 67 | if (extra = @extra) && @extra.size > 0 68 | io.write_byte extra.size.to_u8 69 | io.write(extra) 70 | end 71 | 72 | if name = @name 73 | io << name 74 | io.write_byte 0_u8 75 | end 76 | 77 | if comment = @comment 78 | io << comment 79 | io.write_byte 0_u8 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/lattice-core/hotfixes/ssl_socket.cr: -------------------------------------------------------------------------------- 1 | abstract class OpenSSL::SSL::Socket 2 | class Server < Socket 3 | def initialize(io, context : Context::Server = Context::Server.new, sync_close : Bool = false) 4 | super(io, context, sync_close) 5 | 6 | ret = LibSSL.ssl_accept(@ssl) 7 | unless ret == 1 8 | io.close if sync_close # this is the hotfix 9 | raise OpenSSL::SSL::Error.new(@ssl, ret, "SSL_accept") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/lattice-core/page.cr: -------------------------------------------------------------------------------- 1 | # this is going to require some work with BakedFile to work correctly as a library 2 | # module Lattice 3 | # class WebPage 4 | # def self.render(content : String, javascript : String) 5 | # render "src/lattice-core/templates/page.slang" 6 | # end 7 | # end 8 | # end 9 | -------------------------------------------------------------------------------- /src/lattice-core/public_storage.cr: -------------------------------------------------------------------------------- 1 | module Lattice::Core 2 | class PublicStorage 3 | BakedFileSystem.load("../../public", __DIR__) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/lattice-core/ring_buffer.cr: -------------------------------------------------------------------------------- 1 | module Lattice 2 | class RingBuffer(T) 3 | property current_index = -1 4 | property size : Int32 = 10 5 | 6 | def initialize(size : Int32 = nil) 7 | if size 8 | @size = size.as(Int32) 9 | else 10 | @size = 25 11 | end 12 | @storage = Array(T | Nil).new(@size,nil) 13 | end 14 | 15 | def storage 16 | @storage 17 | end 18 | 19 | # translates the index (an absolute position) into 20 | # the ring-buffered equivalent so data can be accessed directly 21 | # as if it were a regular array 22 | def calculated_position(index : Int32) 23 | (index + @current_index + 1) % @size 24 | end 25 | 26 | def delete_at( index : Int32) 27 | position = calculated_position(index) 28 | puts "ci: #{@current_index} delete at #{index} position #{position} for #{storage}" 29 | @storage.delete_at position 30 | @current_index -= 1 if position <= @current_index 31 | @storage << nil 32 | puts "after delete_at #{@storage}" 33 | end 34 | 35 | def delete(val : T) 36 | if (pos = @storage.index(val)) 37 | @storage.delete(val) 38 | @storage << nil 39 | @current_index -= 1 if position <= @current_index 40 | end 41 | end 42 | 43 | def []=(index,val) 44 | @storage[calculated_position index] = val 45 | end 46 | 47 | def [](index) 48 | @storage[calculated_position index] 49 | end 50 | 51 | def <<(val : T) 52 | @current_index = (@current_index + 1) % @size 53 | @storage[@current_index] = val 54 | end 55 | 56 | def values 57 | (@storage[@current_index + 1..-1] + @storage[0..@current_index]).compact 58 | end 59 | 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/lattice-core/templates/page.slang: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | meta charset="UTF-8" 4 | link rel="stylesheet" type="text/css" href="/css/style.css" 5 | script type="text/javascript" src="/js/app.js" 6 | script 7 | == javascript 8 | body 9 | 10 | -------------------------------------------------------------------------------- /src/lattice-core/user.cr: -------------------------------------------------------------------------------- 1 | module Lattice 2 | 3 | class UserException < Exception; end 4 | abstract class User 5 | 6 | ACTIVE_USERS = {} of String=>self 7 | 8 | @session : Session? 9 | property socket : HTTP::WebSocket? 10 | property last_activity = Time.now 11 | @subscriptions = [] of Connected::WebObject 12 | 13 | Session.timeout {|id| self.timeout(id)} 14 | 15 | def initialize(@session : Session, @socket : HTTP::WebSocket) 16 | prepare 17 | end 18 | 19 | def initialize(@session : Session) 20 | prepare 21 | end 22 | 23 | def initialize(session_id : String = nil) 24 | if session_id && (session = Session.get(session_id)) 25 | prepare 26 | @session = session 27 | ACTIVE_USERS[session.id] = self 28 | else 29 | 30 | # the user will be created, but not persisted (it won't be added to ACTIVE_USERS 31 | # but any attempt to access things inside the session will cause an exception 32 | end 33 | self 34 | end 35 | 36 | # TODO create a find_or_create with Session type, pass it in directly? 37 | def self.find_or_create(session_id : String) 38 | user = find?(session_id) || new(session_id) 39 | # user = self.find?(session_id.as(String)) 40 | # user = new(session_id) unless user 41 | user.as(self) 42 | end 43 | 44 | def self.find?(session_id : String?) 45 | if session_id && ( u = ACTIVE_USERS[session_id]?) 46 | u.last_activity = Time.now 47 | u.as(self) 48 | end 49 | end 50 | 51 | 52 | def session? 53 | @session 54 | end 55 | 56 | def session 57 | @session.as(Session) 58 | rescue 59 | raise UserException.new "Attempt to access a nil @session in #{self.class}." 60 | end 61 | 62 | def socket=(socket : HTTP::WebSocket) 63 | @socket = socket 64 | end 65 | 66 | def self.socket_closing( socket : HTTP::WebSocket) 67 | puts "User.socket_closing called".colorize(:dark_gray).on(:white) 68 | if (key_value = ACTIVE_USERS.find {|(k,u)| u.socket == socket} ) 69 | user = key_value.last.as(User) 70 | user.close_socket 71 | end 72 | end 73 | 74 | # basically sets @socket to nil so it can be gc'ed by Crystal 75 | def close_socket 76 | puts "user #{self} close_socket called".colorize(:dark_gray).on(:white) 77 | return unless @socket 78 | on_socket_close 79 | # @socket.as(HTTP::WebSocket).close 80 | @socket = nil 81 | end 82 | 83 | def on_socket_close 84 | puts "Remove subscriptions. Socket closing for this #{self}" 85 | end 86 | 87 | # called by #self.timeout when the given id has expired 88 | def timeout 89 | end 90 | 91 | def self.timeout(id : String) 92 | puts "Session timeout for session id #{id}".colorize(:dark_gray).on(:white) 93 | if (user = find? id) 94 | puts "Calling timeout for #{user}" 95 | puts "user.socket: #{user.socket}" 96 | if (socket = user.socket) 97 | puts "Calling WebSocket.close".colorize(:dark_gray).on(:white) 98 | user.close_socket 99 | Connected::WebSocket.close(socket) 100 | end 101 | user.timeout 102 | end 103 | User::ACTIVE_USERS.delete id 104 | puts "Users remaining #{ACTIVE_USERS.size}" 105 | end 106 | 107 | def prepare 108 | if @session 109 | session = @session.as(Session) 110 | @session = session 111 | session.string("last_activity", Time.now.to_s) 112 | end 113 | load 114 | end 115 | 116 | abstract def save : self 117 | abstract def load : self 118 | 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /src/lattice-core/version.cr: -------------------------------------------------------------------------------- 1 | module Lattice::Core 2 | VERSION = "0.1.0" 3 | end 4 | --------------------------------------------------------------------------------