monkrb 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- metadata +252 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require_relative "settings"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
# The tier an app is running under -- CONTEXT.md's MONK_ENV entry.
|
|
5
|
+
# A small, frozen value object wrapping Settings[:monk_env] (never
|
|
6
|
+
# cached across calls itself: each Monk.env call builds a fresh, local
|
|
7
|
+
# instance, cheap since it never crosses a Ractor boundary -- only the
|
|
8
|
+
# String it wraps, read from Settings' already-frozen values, does).
|
|
9
|
+
#
|
|
10
|
+
# Predicates are four plain, hand-written methods, not one
|
|
11
|
+
# define_method(&block) per MONK_ENV_VALUES entry: a method backed by a
|
|
12
|
+
# Proc closure raises "defined with an un-shareable Proc in a different
|
|
13
|
+
# Ractor" the first time a *different* Ractor than the one that defined
|
|
14
|
+
# it calls it -- discovered by this class's own real-Ractor test.
|
|
15
|
+
# Ordinary `def` methods compile to plain bytecode with no Proc
|
|
16
|
+
# involved, so they carry no such restriction (the same reason View's
|
|
17
|
+
# compiled templates -- ADR 0004 -- are real methods, not blocks).
|
|
18
|
+
class Environment
|
|
19
|
+
attr_reader :value
|
|
20
|
+
|
|
21
|
+
def initialize(value)
|
|
22
|
+
@value = value
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def development?
|
|
27
|
+
value == "development"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test?
|
|
31
|
+
value == "test"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def staging?
|
|
35
|
+
value == "staging"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def production?
|
|
39
|
+
value == "production"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.env
|
|
48
|
+
Environment.new(Settings[:monk_env])
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/monk/errors.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
class InvalidMonkEnvError < StandardError
|
|
3
|
+
end
|
|
4
|
+
|
|
5
|
+
class InvalidLogLevelError < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class DuplicateSettingError < StandardError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class MissingSettingError < StandardError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class UnknownSettingError < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class SettingsFrozenError < StandardError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class UnshareableBlockError < StandardError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class UnshareableModelError < StandardError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class UnshareableRouteError < StandardError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class TemplateNotFoundError < StandardError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class TemplateSyntaxError < StandardError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class ScaffoldExistsError < StandardError
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
# Objects responding to #freeze_registry! that Base#freeze! (Seam B) must
|
|
3
|
+
# seal at boot, so a worker Ractor can read their config without tripping
|
|
4
|
+
# Ractor::IsolationError. Not persistence-specific: Monk::Persistence::
|
|
5
|
+
# Registry backends and Monk::Auth both register themselves here.
|
|
6
|
+
def self.freeze_hooks
|
|
7
|
+
@freeze_hooks ||= []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Extracted out of Base#freeze! (docs/history/plan-websocket.md Phase 5 step 20):
|
|
11
|
+
# Monk::Base.freeze! calls this, but so can any process that never
|
|
12
|
+
# touches Monk::Base at all -- Monk::WebSocket::Server's boot script,
|
|
13
|
+
# per Decision 1 -- and still needs Monk::Auth/Monk::Persistence config
|
|
14
|
+
# readable from a worker Ractor. Without this, that config stays an
|
|
15
|
+
# unfrozen Hash, and the first Monk::Auth.verify call from inside a
|
|
16
|
+
# connection Ractor raises Ractor::IsolationError -- the same bug class
|
|
17
|
+
# already hit and fixed for persistence (Phase 4/5) and for Base-booted
|
|
18
|
+
# auth (docs/history/plan-auth.md Phase 5 step 17).
|
|
19
|
+
def self.freeze!
|
|
20
|
+
freeze_hooks.each(&:freeze_registry!)
|
|
21
|
+
Persistence::Model.freeze_all!
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Zero-Clause BSD
|
|
2
|
+
=============
|
|
3
|
+
|
|
4
|
+
Permission to use, copy, modify, and/or distribute this software for
|
|
5
|
+
any purpose with or without fee is hereby granted.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
|
|
8
|
+
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
|
|
9
|
+
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
|
|
10
|
+
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
|
|
11
|
+
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
|
12
|
+
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
|
13
|
+
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/* idiomorph 0.7.3 (Zero-Clause BSD, see idiomorph.LICENSE), vendored from dist/idiomorph.min.js.
|
|
2
|
+
Only change: the `export` line at the end, so it loads as an ES module. */
|
|
3
|
+
var Idiomorph=function(){"use strict";const e=()=>{};const n={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:e,afterNodeAdded:e,beforeNodeMorphed:e,afterNodeMorphed:e,beforeNodeRemoved:e,afterNodeRemoved:e,beforeAttributeUpdated:e},head:{style:"merge",shouldPreserve:e=>e.getAttribute("im-preserve")==="true",shouldReAppend:e=>e.getAttribute("im-re-append")==="true",shouldRemove:e,afterHeadMorphed:e},restoreFocus:true};function t(t,e,n={}){t=d(t);const r=f(e);const o=u(t,r,n);const i=a(o,()=>{return c(o,t,r,e=>{if(e.morphStyle==="innerHTML"){s(e,t,r);return Array.from(t.childNodes)}else{return l(e,t,r)}})});o.pantry.remove();return i}function l(e,t,n){const r=f(t);s(e,r,n,t,t.nextSibling);return Array.from(r.childNodes)}function a(e,t){if(!e.config.restoreFocus)return t();let n=document.activeElement;if(!(n instanceof HTMLInputElement||n instanceof HTMLTextAreaElement)){return t()}const{id:r,selectionStart:o,selectionEnd:i}=n;const l=t();if(r&&r!==document.activeElement?.id){n=e.target.querySelector(`[id="${r}"]`);n?.focus()}if(n&&!n.selectionEnd&&i){n.setSelectionRange(o,i)}return l}const s=function(){function e(e,t,n,r=null,o=null){if(t instanceof HTMLTemplateElement&&n instanceof HTMLTemplateElement){t=t.content;n=n.content}r||=t.firstChild;for(const i of n.childNodes){if(r&&r!=o){const a=d(e,i,r,o);if(a){if(a!==r){m(e,r,a)}p(a,i,e);r=a.nextSibling;continue}}if(i instanceof Element&&e.persistentIds.has(i.id)){const s=h(t,i.id,r,e);p(s,i,e);r=s.nextSibling;continue}const l=u(t,i,r,e);if(l){r=l.nextSibling}}while(r&&r!=o){const c=r;r=r.nextSibling;f(e,c)}}function u(e,t,n,r){if(r.callbacks.beforeNodeAdded(t)===false)return null;if(r.idMap.has(t)){const o=document.createElement(t.tagName);e.insertBefore(o,n);p(o,t,r);r.callbacks.afterNodeAdded(o);return o}else{const i=document.importNode(t,true);e.insertBefore(i,n);r.callbacks.afterNodeAdded(i);return i}}const d=function(){function e(e,t,n,r){let o=null;let i=t.nextSibling;let l=0;let a=n;while(a&&a!=r){if(c(a,t)){if(s(e,a,t)){return a}if(o===null){if(!e.idMap.has(a)){o=a}}}if(o===null&&i&&c(a,i)){l++;i=i.nextSibling;if(l>=2){o=undefined}}if(a.contains(document.activeElement))break;a=a.nextSibling}return o||null}function s(e,t,n){let r=e.idMap.get(t);let o=e.idMap.get(n);if(!o||!r)return false;for(const i of r){if(o.has(i)){return true}}return false}function c(e,t){const n=e;const r=t;return n.nodeType===r.nodeType&&n.tagName===r.tagName&&(!n.id||n.id===r.id)}return e}();function f(e,t){if(e.idMap.has(t)){l(e.pantry,t,null)}else{if(e.callbacks.beforeNodeRemoved(t)===false)return;t.parentNode?.removeChild(t);e.callbacks.afterNodeRemoved(t)}}function m(t,e,n){let r=e;while(r&&r!==n){let e=r;r=r.nextSibling;f(t,e)}return r}function h(e,t,n,r){const o=r.target.id===t&&r.target||r.target.querySelector(`[id="${t}"]`)||r.pantry.querySelector(`[id="${t}"]`);i(o,r);l(e,o,n);return o}function i(t,n){const r=t.id;while(t=t.parentNode){let e=n.idMap.get(t);if(e){e.delete(r);if(!e.size){n.idMap.delete(t)}}}}function l(t,n,r){if(t.moveBefore){try{t.moveBefore(n,r)}catch(e){t.insertBefore(n,r)}}else{t.insertBefore(n,r)}}return e}();const p=function(){function e(e,t,n){if(n.ignoreActive&&e===document.activeElement){return null}if(n.callbacks.beforeNodeMorphed(e,t)===false){return e}if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){m(e,t,n)}else{r(e,t,n);if(!f(e,n)){s(n,e,t)}}n.callbacks.afterNodeMorphed(e,t);return e}function r(e,t,n){let r=t.nodeType;if(r===1){const o=e;const i=t;const l=o.attributes;const a=i.attributes;for(const s of a){if(d(s.name,o,"update",n)){continue}if(o.getAttribute(s.name)!==s.value){o.setAttribute(s.name,s.value)}}for(let e=l.length-1;0<=e;e--){const c=l[e];if(!c)continue;if(!i.hasAttribute(c.name)){if(d(c.name,o,"remove",n)){continue}o.removeAttribute(c.name)}}if(!f(o,n)){u(o,i,n)}}if(r===8||r===3){if(e.nodeValue!==t.nodeValue){e.nodeValue=t.nodeValue}}}function u(n,r,o){if(n instanceof HTMLInputElement&&r instanceof HTMLInputElement&&r.type!=="file"){let e=r.value;let t=n.value;i(n,r,"checked",o);i(n,r,"disabled",o);if(!r.hasAttribute("value")){if(!d("value",n,"remove",o)){n.value="";n.removeAttribute("value")}}else if(t!==e){if(!d("value",n,"update",o)){n.setAttribute("value",e);n.value=e}}}else if(n instanceof HTMLOptionElement&&r instanceof HTMLOptionElement){i(n,r,"selected",o)}else if(n instanceof HTMLTextAreaElement&&r instanceof HTMLTextAreaElement){let e=r.value;let t=n.value;if(d("value",n,"update",o)){return}if(e!==t){n.value=e}if(n.firstChild&&n.firstChild.nodeValue!==e){n.firstChild.nodeValue=e}}}function i(e,t,n,r){const o=t[n],i=e[n];if(o!==i){const l=d(n,e,"update",r);if(!l){e[n]=t[n]}if(o){if(!l){e.setAttribute(n,"")}}else{if(!d(n,e,"remove",r)){e.removeAttribute(n)}}}}function d(e,t,n,r){if(e==="value"&&r.ignoreActiveValue&&t===document.activeElement){return true}return r.callbacks.beforeAttributeUpdated(e,t,n)===false}function f(e,t){return!!t.ignoreActiveValue&&e===document.activeElement&&e!==document.body}return e}();function c(t,e,n,r){if(t.head.block){const o=e.querySelector("head");const i=n.querySelector("head");if(o&&i){const l=m(o,i,t);return Promise.all(l).then(()=>{const e=Object.assign(t,{head:{block:false,ignore:true}});return r(e)})}}return r(t)}function m(e,t,r){let o=[];let i=[];let l=[];let a=[];let s=new Map;for(const n of t.children){s.set(n.outerHTML,n)}for(const u of e.children){let e=s.has(u.outerHTML);let t=r.head.shouldReAppend(u);let n=r.head.shouldPreserve(u);if(e||n){if(t){i.push(u)}else{s.delete(u.outerHTML);l.push(u)}}else{if(r.head.style==="append"){if(t){i.push(u);a.push(u)}}else{if(r.head.shouldRemove(u)!==false){i.push(u)}}}}a.push(...s.values());let c=[];for(const d of a){let n=document.createRange().createContextualFragment(d.outerHTML).firstChild;if(r.callbacks.beforeNodeAdded(n)!==false){if("href"in n&&n.href||"src"in n&&n.src){let t;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});c.push(e)}e.appendChild(n);r.callbacks.afterNodeAdded(n);o.push(n)}}for(const f of i){if(r.callbacks.beforeNodeRemoved(f)!==false){e.removeChild(f);r.callbacks.afterNodeRemoved(f)}}r.head.afterHeadMorphed(e,{added:o,kept:l,removed:i});return c}const u=function(){function e(e,t,n){const{persistentIds:r,idMap:o}=d(e,t);const i=a(n);const l=i.morphStyle||"outerHTML";if(!["innerHTML","outerHTML"].includes(l)){throw`Do not understand how to morph style ${l}`}return{target:e,newContent:t,config:i,morphStyle:l,ignoreActive:i.ignoreActive,ignoreActiveValue:i.ignoreActiveValue,restoreFocus:i.restoreFocus,idMap:o,persistentIds:r,pantry:s(),callbacks:i.callbacks,head:i.head}}function a(e){let t=Object.assign({},n);Object.assign(t,e);t.callbacks=Object.assign({},n.callbacks,e.callbacks);t.head=Object.assign({},n.head,e.head);return t}function s(){const e=document.createElement("div");e.hidden=true;document.body.insertAdjacentElement("afterend",e);return e}function c(e){let t=Array.from(e.querySelectorAll("[id]"));if(e.id){t.push(e)}return t}function u(n,e,r,t){for(const o of t){if(e.has(o.id)){let t=o;while(t){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(o.id);if(t===r)break;t=t.parentElement}}}}function d(e,t){const n=c(e);const r=c(t);const o=f(n,r);let i=new Map;u(i,o,e,n);const l=t.__idiomorphRoot||t;u(i,o,l,r);return{persistentIds:o,idMap:i}}function f(e,t){let n=new Set;let r=new Map;for(const{id:i,tagName:l}of e){if(r.has(i)){n.add(i)}else{r.set(i,l)}}let o=new Set;for(const{id:i,tagName:l}of t){if(o.has(i)){n.add(i)}else if(r.get(i)===l){o.add(i)}}for(const i of n){o.delete(i)}return o}return e}();const{normalizeElement:d,normalizeParent:f}=function(){const o=new WeakSet;function e(e){if(e instanceof Document){return e.documentElement}else{return e}}function r(e){if(e==null){return document.createElement("div")}else if(typeof e==="string"){return r(l(e))}else if(o.has(e)){return e}else if(e instanceof Node){if(e.parentNode){return new i(e)}else{const t=document.createElement("div");t.append(e);return t}}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}class i{constructor(e){this.originalNode=e;this.realParentNode=e.parentNode;this.previousSibling=e.previousSibling;this.nextSibling=e.nextSibling}get childNodes(){const e=[];let t=this.previousSibling?this.previousSibling.nextSibling:this.realParentNode.firstChild;while(t&&t!=this.nextSibling){e.push(t);t=t.nextSibling}return e}querySelectorAll(r){return this.childNodes.reduce((t,e)=>{if(e instanceof Element){if(e.matches(r))t.push(e);const n=e.querySelectorAll(r);for(let e=0;e<n.length;e++){t.push(n[e])}}return t},[])}insertBefore(e,t){return this.realParentNode.insertBefore(e,t)}moveBefore(e,t){return this.realParentNode.moveBefore(e,t)}get __idiomorphRoot(){return this.originalNode}}function l(n){let r=new DOMParser;let e=n.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let t=r.parseFromString(n,"text/html");if(e.match(/<\/html>/)){o.add(t);return t}else{let e=t.firstChild;if(e){o.add(e)}return e}}else{let e=r.parseFromString("<body><template>"+n+"</template></body>","text/html");let t=e.body.querySelector("template").content;o.add(t);return t}}return{normalizeElement:e,normalizeParent:r}}();return{morph:t,defaults:n}}();
|
|
4
|
+
export { Idiomorph };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Monk::Live client runtime: subscribes to the topics a page declares with
|
|
2
|
+
// data-live-topic, applies the HTML patches the server pushes (morphing, so
|
|
3
|
+
// focus and typed text survive), and re-syncs by refetching the page when it
|
|
4
|
+
// may have missed something (ADR 0007, 0011). No JS API: it reports what it
|
|
5
|
+
// does through `monk-live:*` DOM events on `document`.
|
|
6
|
+
//
|
|
7
|
+
// <meta name="monk-live-url" content="ws://localhost:9293">
|
|
8
|
+
// <script type="module" src="/js/monk_live.js"></script>
|
|
9
|
+
//
|
|
10
|
+
// The decision logic lives in protocol.js, tested under plain Node.
|
|
11
|
+
|
|
12
|
+
import { Idiomorph } from "./idiomorph.js";
|
|
13
|
+
import {
|
|
14
|
+
INITIAL_DELAY, nextDelay, topicsFrom, diffTopics, subscribeMessage, unsubscribeMessage, parseMessage, checkSeq, resyncVerdict,
|
|
15
|
+
} from "./protocol.js";
|
|
16
|
+
|
|
17
|
+
const emit = (name, detail = {}) => document.dispatchEvent(new CustomEvent(`monk-live:${name}`, { detail }));
|
|
18
|
+
|
|
19
|
+
// Focus and typed text: idiomorph keeps the focused element (and, with
|
|
20
|
+
// ignoreActiveValue, its value/selection). On top of that:
|
|
21
|
+
// - [data-live-ignore] subtrees are left exactly as the client has them;
|
|
22
|
+
// - <details open> is client state the server never sends, so a patch
|
|
23
|
+
// doesn't close what the user opened.
|
|
24
|
+
const morphOptions = (morphStyle) => ({
|
|
25
|
+
morphStyle,
|
|
26
|
+
ignoreActiveValue: true,
|
|
27
|
+
callbacks: {
|
|
28
|
+
beforeNodeMorphed: (from) => !(from.nodeType === 1 && from.hasAttribute("data-live-ignore")),
|
|
29
|
+
beforeAttributeUpdated: (name, node) => !(node.tagName === "DETAILS" && name === "open"),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const currentTopics = () =>
|
|
34
|
+
topicsFrom([...document.querySelectorAll("[data-live-topic]")].map((el) => el.getAttribute("data-live-topic")));
|
|
35
|
+
|
|
36
|
+
let url;
|
|
37
|
+
let socket;
|
|
38
|
+
let delay = INITIAL_DELAY;
|
|
39
|
+
let lastSeq = 0;
|
|
40
|
+
let subscribed = [];
|
|
41
|
+
let everConnected = false;
|
|
42
|
+
let needsResync = false;
|
|
43
|
+
let resyncing = false;
|
|
44
|
+
let resyncAgain = false;
|
|
45
|
+
let resyncRetryDelay = INITIAL_DELAY;
|
|
46
|
+
let resyncRetryTimer = null;
|
|
47
|
+
let stopped = false;
|
|
48
|
+
|
|
49
|
+
function applyOp({ target, mode, html }) {
|
|
50
|
+
let nodes;
|
|
51
|
+
try {
|
|
52
|
+
nodes = [...document.querySelectorAll(target)];
|
|
53
|
+
} catch {
|
|
54
|
+
console.warn(`monk-live: invalid selector ${JSON.stringify(target)}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const live = nodes.filter((node) => !node.closest("[data-live-ignore]"));
|
|
58
|
+
|
|
59
|
+
for (const node of live) {
|
|
60
|
+
switch (mode) {
|
|
61
|
+
case "morph": Idiomorph.morph(node, html, morphOptions("outerHTML")); break;
|
|
62
|
+
case "replace": node.outerHTML = html; break;
|
|
63
|
+
case "append": node.insertAdjacentHTML("beforeend", html); break;
|
|
64
|
+
case "prepend": node.insertAdjacentHTML("afterbegin", html); break;
|
|
65
|
+
case "remove": node.remove(); break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
emit("patched", { target, mode, matched: live.length });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function onMessage(event) {
|
|
72
|
+
const message = parseMessage(event.data);
|
|
73
|
+
|
|
74
|
+
switch (message.kind) {
|
|
75
|
+
case "envelope": {
|
|
76
|
+
const verdict = checkSeq(lastSeq, message.seq);
|
|
77
|
+
lastSeq = message.seq;
|
|
78
|
+
message.ops.forEach(applyOp);
|
|
79
|
+
if (verdict === "gap") {
|
|
80
|
+
emit("gap", { seq: message.seq });
|
|
81
|
+
resync();
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "subscribed":
|
|
86
|
+
subscribed = [...new Set([...subscribed, ...message.topics])].sort();
|
|
87
|
+
if (message.denied.length) console.warn("monk-live: subscription denied for", message.denied);
|
|
88
|
+
emit("subscribed", { topics: message.topics, denied: message.denied });
|
|
89
|
+
if (needsResync) resync();
|
|
90
|
+
break;
|
|
91
|
+
case "unsubscribed":
|
|
92
|
+
subscribed = subscribed.filter((topic) => !message.topics.includes(topic));
|
|
93
|
+
break;
|
|
94
|
+
case "error":
|
|
95
|
+
console.warn("monk-live: server error", message.reason);
|
|
96
|
+
break;
|
|
97
|
+
default:
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stop(reason) {
|
|
103
|
+
stopped = true;
|
|
104
|
+
needsResync = false;
|
|
105
|
+
clearTimeout(resyncRetryTimer);
|
|
106
|
+
socket?.close();
|
|
107
|
+
emit("stopped", { reason });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The page's own view is the only definition of what a region looks like
|
|
111
|
+
// (ADR 0011): refetch it and morph it in. If what comes back isn't the app
|
|
112
|
+
// page (an error, a redirect to a login screen), stop instead of morphing.
|
|
113
|
+
async function resync() {
|
|
114
|
+
if (resyncing) {
|
|
115
|
+
resyncAgain = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
resyncing = true;
|
|
119
|
+
try {
|
|
120
|
+
const response = await fetch(location.href, {
|
|
121
|
+
credentials: "same-origin", cache: "no-store", headers: { Accept: "text/html" },
|
|
122
|
+
});
|
|
123
|
+
const verdict = resyncVerdict({
|
|
124
|
+
ok: response.ok, redirected: response.redirected, status: response.status,
|
|
125
|
+
contentType: response.headers.get("content-type"),
|
|
126
|
+
});
|
|
127
|
+
if (verdict) {
|
|
128
|
+
stop(verdict);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const page = new DOMParser().parseFromString(await response.text(), "text/html");
|
|
132
|
+
Idiomorph.morph(document.body, page.body, morphOptions("innerHTML"));
|
|
133
|
+
needsResync = false;
|
|
134
|
+
resyncRetryDelay = INITIAL_DELAY;
|
|
135
|
+
reconcileTopics();
|
|
136
|
+
emit("resynced");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
needsResync = true;
|
|
139
|
+
console.warn("monk-live: resync failed", error);
|
|
140
|
+
emit("resync-failed");
|
|
141
|
+
scheduleResyncRetry();
|
|
142
|
+
} finally {
|
|
143
|
+
resyncing = false;
|
|
144
|
+
if (resyncAgain && !stopped) {
|
|
145
|
+
resyncAgain = false;
|
|
146
|
+
resync();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A failed refetch (network down, server restarting) is retried with the same
|
|
152
|
+
// backoff as reconnects, not only on the next reconnect or gap.
|
|
153
|
+
function scheduleResyncRetry() {
|
|
154
|
+
if (resyncRetryTimer || stopped) return;
|
|
155
|
+
resyncRetryTimer = setTimeout(() => {
|
|
156
|
+
resyncRetryTimer = null;
|
|
157
|
+
if (needsResync && !stopped) resync();
|
|
158
|
+
}, resyncRetryDelay);
|
|
159
|
+
resyncRetryDelay = nextDelay(resyncRetryDelay);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A resync can bring new (or drop old) data-live-topic regions.
|
|
163
|
+
function reconcileTopics() {
|
|
164
|
+
if (socket?.readyState !== WebSocket.OPEN) return;
|
|
165
|
+
const { add, remove } = diffTopics(subscribed, currentTopics());
|
|
166
|
+
if (add.length) socket.send(subscribeMessage(add));
|
|
167
|
+
if (remove.length) socket.send(unsubscribeMessage(remove));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function connect() {
|
|
171
|
+
socket = new WebSocket(url);
|
|
172
|
+
socket.onopen = () => {
|
|
173
|
+
delay = INITIAL_DELAY;
|
|
174
|
+
lastSeq = 0;
|
|
175
|
+
subscribed = [];
|
|
176
|
+
// Patches may have been missed while we were away: resync once the
|
|
177
|
+
// server has acknowledged our subscriptions (not before, or a patch
|
|
178
|
+
// published between the fetch and the subscribe would be lost).
|
|
179
|
+
needsResync = everConnected;
|
|
180
|
+
everConnected = true;
|
|
181
|
+
socket.send(subscribeMessage(currentTopics()));
|
|
182
|
+
emit("connected");
|
|
183
|
+
};
|
|
184
|
+
socket.onmessage = onMessage;
|
|
185
|
+
socket.onclose = () => {
|
|
186
|
+
emit("disconnected");
|
|
187
|
+
if (stopped) return;
|
|
188
|
+
setTimeout(connect, delay);
|
|
189
|
+
delay = nextDelay(delay);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function start() {
|
|
194
|
+
url = document.querySelector('meta[name="monk-live-url"]')?.content;
|
|
195
|
+
if (!url) {
|
|
196
|
+
console.error('monk-live: add <meta name="monk-live-url" content="ws://..."> to the page');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (currentTopics().length === 0) return;
|
|
200
|
+
connect();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
|
204
|
+
else start();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Pure logic for the Monk::Live client: no DOM, no WebSocket, no globals, so
|
|
2
|
+
// it runs under plain `node --test` (test/js/protocol.test.js). The browser
|
|
3
|
+
// glue that uses it is monk_live.js.
|
|
4
|
+
|
|
5
|
+
export const INITIAL_DELAY = 500;
|
|
6
|
+
export const MAX_DELAY = 30000;
|
|
7
|
+
|
|
8
|
+
// Reconnect backoff: double, capped.
|
|
9
|
+
export const nextDelay = (delay) => Math.min(delay * 2, MAX_DELAY);
|
|
10
|
+
|
|
11
|
+
const MODES = ["morph", "replace", "append", "prepend", "remove"];
|
|
12
|
+
|
|
13
|
+
// data-live-topic values ("a:1 b:2") -> a sorted, de-duplicated topic list.
|
|
14
|
+
export function topicsFrom(values) {
|
|
15
|
+
const topics = new Set();
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
for (const topic of String(value ?? "").split(/\s+/)) if (topic) topics.add(topic);
|
|
18
|
+
}
|
|
19
|
+
return [...topics].sort();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function diffTopics(current, wanted) {
|
|
23
|
+
return {
|
|
24
|
+
add: wanted.filter((topic) => !current.includes(topic)),
|
|
25
|
+
remove: current.filter((topic) => !wanted.includes(topic)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const subscribeMessage = (topics) => JSON.stringify({ op: "subscribe", topics });
|
|
30
|
+
export const unsubscribeMessage = (topics) => JSON.stringify({ op: "unsubscribe", topics });
|
|
31
|
+
|
|
32
|
+
const isString = (value) => typeof value === "string";
|
|
33
|
+
|
|
34
|
+
function validOp(op) {
|
|
35
|
+
if (op === null || typeof op !== "object" || op.op !== "patch") return false;
|
|
36
|
+
if (!isString(op.target) || op.target.trim() === "" || !MODES.includes(op.mode)) return false;
|
|
37
|
+
return op.mode === "remove" ? true : isString(op.html);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const toOp = ({ target, mode, html }) => ({ target, mode, html });
|
|
41
|
+
|
|
42
|
+
// One server frame -> { kind: "envelope" | "subscribed" | "unsubscribed" |
|
|
43
|
+
// "error" | "unknown" | "invalid", ... }. Never throws: a frame the client
|
|
44
|
+
// can't understand must not take the connection down.
|
|
45
|
+
export function parseMessage(text) {
|
|
46
|
+
let message;
|
|
47
|
+
try {
|
|
48
|
+
message = JSON.parse(text);
|
|
49
|
+
} catch {
|
|
50
|
+
return { kind: "invalid" };
|
|
51
|
+
}
|
|
52
|
+
if (message === null || typeof message !== "object" || Array.isArray(message)) return { kind: "invalid" };
|
|
53
|
+
|
|
54
|
+
switch (message.op) {
|
|
55
|
+
case "patch":
|
|
56
|
+
if (!Number.isInteger(message.seq) || !validOp(message)) return { kind: "invalid" };
|
|
57
|
+
return { kind: "envelope", seq: message.seq, ops: [toOp(message)] };
|
|
58
|
+
case "batch":
|
|
59
|
+
if (!Number.isInteger(message.seq) || !Array.isArray(message.ops) || message.ops.length === 0) {
|
|
60
|
+
return { kind: "invalid" };
|
|
61
|
+
}
|
|
62
|
+
if (!message.ops.every(validOp)) return { kind: "invalid" };
|
|
63
|
+
return { kind: "envelope", seq: message.seq, ops: message.ops.map(toOp) };
|
|
64
|
+
case "subscribed":
|
|
65
|
+
return { kind: "subscribed", topics: message.topics ?? [], denied: message.denied ?? [] };
|
|
66
|
+
case "unsubscribed":
|
|
67
|
+
return { kind: "unsubscribed", topics: message.topics ?? [] };
|
|
68
|
+
case "error":
|
|
69
|
+
return { kind: "error", reason: message.reason };
|
|
70
|
+
default:
|
|
71
|
+
return { kind: "unknown", op: message.op };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// seq is per connection and starts at 1, so anything but last + 1 means a
|
|
76
|
+
// frame was missed, duplicated or reordered: resync.
|
|
77
|
+
export const checkSeq = (last, seq) => (seq === last + 1 ? "ok" : "gap");
|
|
78
|
+
|
|
79
|
+
// Whether a refetched page may be morphed in. null means yes; anything else
|
|
80
|
+
// is the reason to stop instead. A redirect is checked first: a redirect to a
|
|
81
|
+
// login screen usually answers 200 HTML, which would otherwise pass.
|
|
82
|
+
export function resyncVerdict({ ok, redirected, status, contentType }) {
|
|
83
|
+
if (redirected) return "redirected";
|
|
84
|
+
if (!ok) return `status_${status}`;
|
|
85
|
+
if (!String(contentType ?? "").includes("text/html")) return "not_html";
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Live
|
|
5
|
+
# The server -> client wire shape (docs/history/plan-live.md "Wire protocol"): a
|
|
6
|
+
# `patch` (one DOM operation: a CSS target, a mode, some html) or a
|
|
7
|
+
# `batch` of them. Built as frozen Hashes and encoded to one frozen JSON
|
|
8
|
+
# String, so a publisher can hand the same object to every subscriber
|
|
9
|
+
# (ADR 0010). No `seq` here: it is per connection, so it can only be
|
|
10
|
+
# stamped at the edge, where the connection writes the frame.
|
|
11
|
+
module Envelope
|
|
12
|
+
MODES = %i[morph replace append prepend remove].freeze
|
|
13
|
+
|
|
14
|
+
def self.patch(target:, mode:, html: nil)
|
|
15
|
+
check_target!(target)
|
|
16
|
+
check_mode!(mode)
|
|
17
|
+
if mode == :remove
|
|
18
|
+
raise ArgumentError, "a remove patch carries no html" if html
|
|
19
|
+
elsif html.nil?
|
|
20
|
+
raise ArgumentError, "a #{mode} patch needs html"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
envelope = { "op" => "patch", "target" => target, "mode" => mode.to_s }
|
|
24
|
+
envelope["html"] = html if html
|
|
25
|
+
envelope.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.batch(ops)
|
|
29
|
+
raise ArgumentError, "a batch needs at least one op" if ops.empty?
|
|
30
|
+
|
|
31
|
+
{ "op" => "batch", "ops" => ops.dup.freeze }.freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.encode(envelope)
|
|
35
|
+
JSON.generate(envelope).freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.check_mode!(mode)
|
|
39
|
+
return if MODES.include?(mode)
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "unknown mode #{mode.inspect} (known: #{MODES.inspect})"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.check_target!(target)
|
|
45
|
+
return if target.is_a?(String) && !target.strip.empty?
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "target must be a non-blank CSS selector String, got #{target.inspect}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Live
|
|
3
|
+
# View helpers, mixed into Monk::Context when "monk/live" is required
|
|
4
|
+
# (before Boot, like any other Context method).
|
|
5
|
+
module Helpers
|
|
6
|
+
# The attribute the client runtime subscribes from:
|
|
7
|
+
# <ul id="contacts" <%= live_topic "contacts:#{current_user.id}" %>>
|
|
8
|
+
# Refuses, at render time, a topic the server would refuse at
|
|
9
|
+
# subscribe time (Session::TOPIC_FORMAT), so the mistake shows up in
|
|
10
|
+
# the developer's page instead of as a silent denial in production.
|
|
11
|
+
def live_topic(*topics)
|
|
12
|
+
raise ArgumentError, "live_topic needs at least one topic" if topics.empty?
|
|
13
|
+
|
|
14
|
+
names = topics.map(&:to_s)
|
|
15
|
+
bad = names.grep_v(Session::TOPIC_FORMAT)
|
|
16
|
+
raise ArgumentError, "invalid live topic(s): #{bad.inspect}" unless bad.empty?
|
|
17
|
+
|
|
18
|
+
raw(%(data-live-topic="#{names.join(" ")}"))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Live
|
|
3
|
+
# Who may subscribe to which topic (ADR 0009): deny by default. Rules
|
|
4
|
+
# are matched in declaration order and the first match decides, like
|
|
5
|
+
# routes, so a specific rule can sit above a general one. A topic no
|
|
6
|
+
# rule matches is denied, and so is an anonymous subject (nil) unless
|
|
7
|
+
# the matching rule says `anonymous: true`: a block like
|
|
8
|
+
# `topic == "contacts:#{subject}"` would otherwise let a nil subject
|
|
9
|
+
# in through "contacts:". A block that raises fails closed.
|
|
10
|
+
module Policy
|
|
11
|
+
Rule = Data.define(:pattern, :anonymous, :check) do
|
|
12
|
+
def matches?(topic)
|
|
13
|
+
if pattern.end_with?("*")
|
|
14
|
+
topic.start_with?(pattern.chomp("*"))
|
|
15
|
+
else
|
|
16
|
+
topic == pattern
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.build_rule(pattern, anonymous, block)
|
|
22
|
+
unless pattern.is_a?(String) && !pattern.strip.empty? && valid_wildcard?(pattern)
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"pattern must be a topic or a topic prefix ending in a single *, got #{pattern.inspect}"
|
|
25
|
+
end
|
|
26
|
+
raise ArgumentError, "authorize needs a block: { |subject, topic| ... }" unless block
|
|
27
|
+
|
|
28
|
+
Rule.new(pattern: pattern.dup.freeze, anonymous: anonymous, check: shareable(block))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.allowed?(rules, subject, topic)
|
|
32
|
+
rule = rules.find { |candidate| candidate.matches?(topic) }
|
|
33
|
+
return false unless rule
|
|
34
|
+
return false if subject.nil? && !rule.anonymous
|
|
35
|
+
|
|
36
|
+
rule.check.call(subject, topic) ? true : false
|
|
37
|
+
rescue StandardError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.valid_wildcard?(pattern)
|
|
42
|
+
pattern.count("*").zero? || (pattern.count("*") == 1 && pattern.end_with?("*"))
|
|
43
|
+
end
|
|
44
|
+
private_class_method :valid_wildcard?
|
|
45
|
+
|
|
46
|
+
# Same constraint, same message shape, as Server#run's block.
|
|
47
|
+
def self.shareable(block)
|
|
48
|
+
Ractor.make_shareable(block)
|
|
49
|
+
rescue ArgumentError, Ractor::IsolationError => e
|
|
50
|
+
raise Monk::UnshareableBlockError,
|
|
51
|
+
"Monk::Live.authorize block is not Ractor-shareable: #{e.message} " \
|
|
52
|
+
"(build it where self is shareable, e.g. at class-body scope, not inline in a script's " \
|
|
53
|
+
"top-level block)"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :shareable
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Live
|
|
3
|
+
# Renders a fragment once, wraps it in an Envelope, and broadcasts the
|
|
4
|
+
# frozen JSON to every subscriber of a topic (ADR 0010). Wraps anything
|
|
5
|
+
# with Registry's #broadcast, so a RedisFanout makes it cross-process
|
|
6
|
+
# without this class knowing (ADR 0008). Frozen, so a module-level
|
|
7
|
+
# publisher is readable from any Ractor.
|
|
8
|
+
#
|
|
9
|
+
# `to:`, `partial:` and `mode:` are reserved keywords; every other
|
|
10
|
+
# keyword is a local for the partial (read as `locals[:name]`).
|
|
11
|
+
class Publisher
|
|
12
|
+
def initialize(registry)
|
|
13
|
+
@registry = registry
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def patch(topic, to:, partial:, mode: :morph, **locals)
|
|
18
|
+
broadcast(topic, Publisher.build_op(to, partial, mode, locals))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def append(topic, to:, partial:, **locals)
|
|
22
|
+
patch(topic, to: to, partial: partial, mode: :append, **locals)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def prepend(topic, to:, partial:, **locals)
|
|
26
|
+
patch(topic, to: to, partial: partial, mode: :prepend, **locals)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remove(topic, to:)
|
|
30
|
+
broadcast(topic, Publisher.build_op(to, nil, :remove, {}))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Every op renders inside the block, so a failure raises before
|
|
34
|
+
# anything is sent; an empty batch sends nothing.
|
|
35
|
+
def batch(topic)
|
|
36
|
+
builder = Batch.new
|
|
37
|
+
yield builder
|
|
38
|
+
return true if builder.ops.empty?
|
|
39
|
+
|
|
40
|
+
broadcast(topic, Envelope.batch(builder.ops))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Collects ops for #batch; same vocabulary as the publisher itself.
|
|
44
|
+
class Batch
|
|
45
|
+
attr_reader :ops
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@ops = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def patch(to:, partial:, mode: :morph, **locals)
|
|
52
|
+
@ops << Publisher.build_op(to, partial, mode, locals)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def append(to:, partial:, **locals)
|
|
56
|
+
patch(to: to, partial: partial, mode: :append, **locals)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def prepend(to:, partial:, **locals)
|
|
60
|
+
patch(to: to, partial: partial, mode: :prepend, **locals)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remove(to:)
|
|
64
|
+
@ops << Publisher.build_op(to, nil, :remove, {})
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Validates before rendering, so a bad mode or target never costs a
|
|
69
|
+
# render (or a half-built batch).
|
|
70
|
+
def self.build_op(target, partial, mode, locals)
|
|
71
|
+
Envelope.check_target!(target)
|
|
72
|
+
Envelope.check_mode!(mode)
|
|
73
|
+
return Envelope.patch(target: target, mode: :remove) if mode == :remove
|
|
74
|
+
|
|
75
|
+
Envelope.patch(target: target, mode: mode, html: Renderer.render(partial, **locals))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Topics are Symbols on the Registry (RedisFanout's channel names
|
|
81
|
+
# come back as Symbols), Strings in app code.
|
|
82
|
+
def broadcast(topic, envelope)
|
|
83
|
+
unless topic.is_a?(String) || topic.is_a?(Symbol)
|
|
84
|
+
raise ArgumentError, "topic must be a String or Symbol, got #{topic.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@registry.broadcast(topic.to_sym, Envelope.encode(envelope))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|