├── .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 | "#{@element_options.fetch("type","div")}>"
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 |
--------------------------------------------------------------------------------