lively 0.18.0 → 0.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a673a59bd33ad2da10a565846eb708b9dc2390745e22a468763758ce8f6d3296
4
- data.tar.gz: 4366967a33057ad99f803015799f63574f1fd2fc393053cd37f30dacd5881232
3
+ metadata.gz: d4f90097ca71e02dffa056e9251b96a63f0d45262ad5eddd0f073d2d02d42b6e
4
+ data.tar.gz: 214a19575d3f3fa624a3d71494b888feccebc18774fa1e38255ec4ceb90c84a6
5
5
  SHA512:
6
- metadata.gz: e5bc31ac8e9c2dd9f7f3f400080de7d12b625a9795eea0601b03c2c5f5fde79a3d8bd1f185684655c2ada88ffe7f1113c9ec11c67a13969031bdaa14734644e3
7
- data.tar.gz: b90a6057a65af77b8fb5221e6760893e78e7d39cc7e986180d0ea97ded8510130a7d372572bec213d74e1538f55fe43b61295ffad66464db949b980b04ee1b9d
6
+ metadata.gz: 23cc87763d59471eaabdfee09f234f389c3576f7bb6d59df85f65e0205d7f6244710c20cfed77c3e0543513f4db581664e776062383c9a05491673ca8bfc88b8
7
+ data.tar.gz: 5b905206f71fcea3f15da3f6db3f0e8fcd9f1bd85f06278654d621affb4d72cf42828b828a1f6821602a98f8af5b8441fe701a4785a7626b4327f972dad8b03d
checksums.yaml.gz.sig CHANGED
Binary file
@@ -99,7 +99,7 @@ Application = Lively::Application[GameView, game_state: GameState.new]
99
99
 
100
100
  The `game_state:` keyword is passed to every `GameView` instance — whether created by the initial page load or by a WebSocket reconnection. This means all connected browsers share the same `GameState` object.
101
101
 
102
- For more complex applications, subclass {ruby Lively::Application} and override `#state`, `#allowed_views`, and `#body`:
102
+ For more complex applications, subclass {ruby Lively::Application} and add routes with `#configure_routes`:
103
103
 
104
104
  ```ruby
105
105
  class Application < Lively::Application
@@ -116,26 +116,34 @@ class Application < Lively::Application
116
116
  super
117
117
  end
118
118
 
119
- def body(request)
120
- case request.path
121
- when "/"
122
- DisplayView.new(**state)
123
- when "/control"
124
- ControlView.new(**state)
119
+ def configure_routes(router)
120
+ super
121
+
122
+ router.get("/") do
123
+ render(DisplayView.new(**state))
124
+ end
125
+
126
+ router.get("/control") do |_request, parameters|
127
+ render(ControlView.new(**state, mode: parameters["mode"]))
125
128
  end
126
129
  end
127
130
 
131
+ # Unmatched routes are passed here:
128
132
  def handle(request)
129
- if body = self.body(request)
130
- page = Lively::Pages::Index.new(title: "My App", body: body)
131
- Protocol::HTTP::Response[200, [], [page.call]]
132
- else
133
- Protocol::HTTP::Response[404, [], ["Not Found"]]
134
- end
133
+ delegate.call(request)
134
+ end
135
+
136
+ private
137
+
138
+ def render(body)
139
+ page = Lively::Pages::Index.new(title: "My App", body: body)
140
+ Protocol::HTTP::Response[200, [], [page.call]]
135
141
  end
136
142
  end
137
143
  ```
138
144
 
145
+ Routes match exact paths and may accept one or more HTTP methods. Query parameters are decoded using `protocol-url` and passed to the handler as its second argument. Routes without an explicit method accept every method.
146
+
139
147
  ## Live Reloading
140
148
 
141
149
  To enable live reloading, add the `io-watch` gem to your `gems.rb` file:
@@ -8,6 +8,7 @@ require "protocol/http/middleware"
8
8
  require "async/websocket/adapters/http"
9
9
 
10
10
  require_relative "resolver"
11
+ require_relative "router"
11
12
  require_relative "pages/index"
12
13
  require_relative "hello_world"
13
14
 
@@ -22,7 +23,7 @@ module Lively
22
23
  #
23
24
  # Use {.[]} to create a simple application class for a single view, optionally
24
25
  # with shared state. For more complex applications, subclass and override
25
- # {#allowed_views}, {#state}, and {#body}.
26
+ # {#allowed_views}, {#state}, and {#configure_routes}.
26
27
  class Application < Protocol::HTTP::Middleware
27
28
  VIEWS = [HelloWorld].freeze
28
29
  STATE = {}.freeze
@@ -98,22 +99,36 @@ module Lively
98
99
  Pages::Index.new(title: self.title, body: self.body)
99
100
  end
100
101
 
101
- # Handle a standard HTTP request.
102
+ # Handle a standard HTTP request which did not match a configured route.
102
103
  # @parameter request [Protocol::HTTP::Request] The incoming HTTP request.
103
104
  # @returns [Protocol::HTTP::Response] The HTTP response with the rendered page.
104
105
  def handle(request)
105
106
  return Protocol::HTTP::Response[200, [], [self.index.call]]
106
107
  end
107
108
 
109
+ # Add the standard application routes to the given router.
110
+ # Override this method and call `super` to add application-specific routes.
111
+ # @parameter router [Router] The router to configure.
112
+ def configure_routes(router)
113
+ router.route("/live") do |request|
114
+ Async::WebSocket::Adapters::HTTP.open(request, &self.method(:live)) || Protocol::HTTP::Response[400]
115
+ end
116
+ end
117
+
118
+ # The router for this application. Unmatched requests are handled separately
119
+ # by {#handle}.
120
+ # @returns [Router] The configured router.
121
+ def router
122
+ @router ||= Router.new.tap do |router|
123
+ configure_routes(router)
124
+ end
125
+ end
126
+
108
127
  # Process an incoming HTTP request.
109
128
  # @parameter request [Protocol::HTTP::Request] The incoming HTTP request.
110
129
  # @returns [Protocol::HTTP::Response] The appropriate response for the request.
111
130
  def call(request)
112
- if request.path == "/live"
113
- return Async::WebSocket::Adapters::HTTP.open(request, &self.method(:live)) || Protocol::HTTP::Response[400]
114
- else
115
- return handle(request)
116
- end
131
+ return router.call(request) || handle(request)
117
132
  end
118
133
  end
119
134
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "json"
7
+ require "xrb/markup"
8
+ require "xrb/tag"
9
+ require "xrb/template"
10
+
11
+ module Lively
12
+ # Represents a complete HTML document.
13
+ #
14
+ # A page combines application content with the stylesheets, import map,
15
+ # JavaScript modules, and body attributes required to present it. Applications
16
+ # can use this class directly or subclass it to provide shared defaults.
17
+ class Page
18
+ TEMPLATE = XRB::Template.load_file(File.expand_path("page.xrb", __dir__))
19
+
20
+ # Initialize a new page.
21
+ # @parameter title [String] The document title.
22
+ # @parameter body [Object | Nil] The document body. The result of `to_html` is interpolated into the template.
23
+ # @parameter icon [String | Nil] The favicon URL.
24
+ # @parameter stylesheets [Array(String | Hash)] Stylesheets in document order. Hash entries specify link attributes.
25
+ # @parameter imports [Hash] JavaScript import map entries.
26
+ # @parameter modules [Array(String)] JavaScript module URLs in document order.
27
+ # @parameter body_attributes [Hash] Attributes applied to the body element.
28
+ def initialize(title: "Lively", body: nil, icon: nil, stylesheets: [], imports: {}, modules: [], body_attributes: {})
29
+ @title = title
30
+ @body = body
31
+ @icon = icon
32
+ @stylesheets = stylesheets
33
+ @imports = imports
34
+ @modules = modules
35
+ @body_attributes = body_attributes
36
+ @template = TEMPLATE
37
+ end
38
+
39
+ # @attribute [String] The document title.
40
+ attr :title
41
+
42
+ # @attribute [Object | Nil] The document body.
43
+ attr :body
44
+
45
+ # @attribute [String | Nil] The favicon URL.
46
+ attr :icon
47
+
48
+ # @attribute [Array(String | Hash)] Stylesheets in document order.
49
+ attr :stylesheets
50
+
51
+ # @attribute [Hash] JavaScript import map entries.
52
+ attr :imports
53
+
54
+ # @attribute [Array(String)] JavaScript module URLs in document order.
55
+ attr :modules
56
+
57
+ # @attribute [Hash] Attributes applied to the body element.
58
+ attr :body_attributes
59
+
60
+ # @attribute [XRB::Template] The document template.
61
+ attr :template
62
+
63
+ # The opening body tag including configured attributes.
64
+ # @returns [XRB::Tag]
65
+ def body_tag
66
+ XRB::Tag.opened("body", @body_attributes)
67
+ end
68
+
69
+ # A stylesheet link tag for the given URL or attributes.
70
+ # @parameter stylesheet [String | Hash] The stylesheet URL or link attributes.
71
+ # @returns [XRB::Tag]
72
+ def stylesheet_tag(stylesheet)
73
+ attributes = if stylesheet.respond_to?(:to_hash)
74
+ stylesheet.to_hash
75
+ else
76
+ {href: stylesheet}
77
+ end
78
+
79
+ XRB::Tag.closed("link", {rel: "stylesheet", type: "text/css"}.merge(attributes))
80
+ end
81
+
82
+ # The rendered body content.
83
+ # @returns [Object]
84
+ def body_content
85
+ @body&.to_html || "No body specified!"
86
+ end
87
+
88
+ # The serialized JavaScript import map.
89
+ # @returns [XRB::MarkupString]
90
+ def import_map
91
+ json = JSON.pretty_generate(imports: @imports)
92
+ json = json.gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026")
93
+
94
+ XRB::MarkupString.raw(json)
95
+ end
96
+
97
+ # Render this page to a string.
98
+ # @returns [String]
99
+ def call
100
+ @template.to_string(self)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>#{self.title}</title>
5
+
6
+ <meta charset="UTF-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+
9
+ <?r if self.icon ?>
10
+ <link rel="icon" type="image/png" href="#{self.icon}" />
11
+ <?r end ?>
12
+ <?r self.stylesheets.each do |stylesheet| ?>
13
+ #{self.stylesheet_tag(stylesheet)}
14
+ <?r end ?>
15
+
16
+ <?r unless self.imports.empty? ?>
17
+ <script type="importmap">
18
+ #{self.import_map}
19
+ </script>
20
+ <?r end ?>
21
+
22
+ <?r self.modules.each do |source| ?>
23
+ <script type="module" src="#{source}"></script>
24
+ <?r end ?>
25
+ </head>
26
+
27
+ #{self.body_tag}
28
+ #{self.body_content}
29
+ </body>
30
+ </html>
@@ -3,7 +3,7 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2021-2026, by Samuel Williams.
5
5
 
6
- require "xrb/template"
6
+ require_relative "../page"
7
7
 
8
8
  # @namespace
9
9
  module Lively
@@ -14,31 +14,31 @@ module Lively
14
14
  # This class renders the initial HTML page that users see when they visit
15
15
  # a Lively application. It uses an XRB template to generate the page structure
16
16
  # and embeds the Live view component for dynamic content.
17
- class Index
17
+ class Index < Page
18
+ ICON = "/_static/icon.png"
19
+ STYLESHEETS = [
20
+ {href: "/_static/site.css", media: "screen"}.freeze,
21
+ {href: "/_static/index.css", media: "screen"}.freeze,
22
+ ].freeze
23
+ IMPORTS = {
24
+ "live" => "/_components/@socketry/live/Live.js",
25
+ "live-audio" => "/_components/@socketry/live-audio/Live/Audio.js",
26
+ "morphdom" => "/_components/morphdom/morphdom-esm.js",
27
+ }.freeze
28
+ MODULES = ["/application.js"].freeze
29
+
18
30
  # Initialize a new index page.
19
31
  # @parameter title [String] The title of the page.
20
32
  # @parameter body [Object] The body content of the page.
21
33
  def initialize(title: "Lively", body: nil)
22
- @title = title
23
- @body = body
24
-
25
- path = File.expand_path("index.xrb", __dir__)
26
- @template = XRB::Template.load_file(path)
27
- end
28
-
29
- # @attribute [String] The title of the page.
30
- attr :title
31
-
32
- # @attribute [Object] The body content of the page.
33
- attr :body
34
-
35
- # @attribute [XRB::Template] The XRB template for rendering the page.
36
- attr :template
37
-
38
- # Render this page to a string.
39
- # @returns [String] The rendered HTML for this page.
40
- def call
41
- @template.to_string(self)
34
+ super(
35
+ title: title,
36
+ body: body,
37
+ icon: ICON,
38
+ stylesheets: STYLESHEETS,
39
+ imports: IMPORTS,
40
+ modules: MODULES,
41
+ )
42
42
  end
43
43
  end
44
44
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http"
7
+ require "protocol/url"
8
+
9
+ module Lively
10
+ # Dispatches HTTP requests to handlers using exact path and method matches.
11
+ #
12
+ # Request targets are parsed with {Protocol::URL::Reference}. Handlers receive
13
+ # the original request and the decoded query parameters.
14
+ class Router
15
+ EMPTY_PARAMETERS = {}.freeze
16
+ ANY_METHOD = nil
17
+ private_constant :EMPTY_PARAMETERS, :ANY_METHOD
18
+
19
+ # Initialize a router.
20
+ # @yields {|router| ...} The router to configure.
21
+ def initialize
22
+ @routes = {}
23
+
24
+ yield self if block_given?
25
+ end
26
+
27
+ # Add a route.
28
+ #
29
+ # When `methods` is omitted, the handler accepts every HTTP method. Otherwise,
30
+ # it may be a single method or an array of methods.
31
+ #
32
+ # @parameter path [String] The absolute path to match.
33
+ # @parameter methods [String | Symbol | Array(String | Symbol) | Nil] The accepted HTTP methods.
34
+ # @yields {|request, parameters| ...} The route handler.
35
+ # @parameter request [Protocol::HTTP::Request] The original request.
36
+ # @parameter parameters [Hash] The decoded query parameters.
37
+ # @returns [Router] The router.
38
+ def route(path, methods: nil, &handler)
39
+ raise ArgumentError, "A route handler is required!" unless handler
40
+
41
+ path = route_path(path)
42
+ methods = route_methods(methods)
43
+ handlers = (@routes[path] ||= {})
44
+
45
+ methods.each do |method|
46
+ if handlers.key?(method)
47
+ raise ArgumentError, "Route already defined for #{method || "any method"} #{path}!"
48
+ end
49
+
50
+ handlers[method] = handler
51
+ end
52
+
53
+ return self
54
+ end
55
+
56
+ Protocol::HTTP::Methods.each do |name, method|
57
+ # Add a route for this HTTP method.
58
+ # @parameter path [String] The absolute path to match.
59
+ # @yields {|request, parameters| ...} The route handler.
60
+ # @returns [Router] The router.
61
+ define_method(name) do |path, &handler|
62
+ route(path, methods: method, &handler)
63
+ end
64
+ end
65
+
66
+ # Dispatch a request to a matching route.
67
+ # @parameter request [Protocol::HTTP::Request] The request to dispatch.
68
+ # @returns [Protocol::HTTP::Response | Nil] The handler or error response, or `nil` when no path matches.
69
+ def call(request)
70
+ reference = parse_reference(request.path)
71
+ return Protocol::HTTP::Response[400] unless reference
72
+
73
+ unless handlers = @routes[reference.path]
74
+ return nil
75
+ end
76
+
77
+ unless handler = handlers[request.method] || handlers[ANY_METHOD]
78
+ allowed_methods = handlers.keys.compact.sort.join(", ")
79
+ return Protocol::HTTP::Response[405, [["allow", allowed_methods]]]
80
+ end
81
+
82
+ parameters = parse_query(reference)
83
+ return Protocol::HTTP::Response[400] unless parameters
84
+
85
+ return handler.call(request, parameters)
86
+ end
87
+
88
+ private
89
+
90
+ def route_path(path)
91
+ url = Protocol::URL[path]
92
+
93
+ unless url && !url.is_a?(Protocol::URL::Absolute)
94
+ raise ArgumentError, "Route must be an absolute path: #{path.inspect}!"
95
+ end
96
+
97
+ reference = Protocol::URL::Reference[url]
98
+
99
+ unless reference.path.absolute?
100
+ raise ArgumentError, "Route must be an absolute path: #{path.inspect}!"
101
+ end
102
+
103
+ if reference.query? || reference.fragment?
104
+ raise ArgumentError, "Route path cannot include a query or fragment: #{path.inspect}!"
105
+ end
106
+
107
+ return reference.path.freeze
108
+ end
109
+
110
+ def route_methods(methods)
111
+ return [ANY_METHOD] unless methods
112
+
113
+ methods = Array(methods)
114
+ raise ArgumentError, "At least one HTTP method is required!" if methods.empty?
115
+
116
+ return methods.map do |method|
117
+ raise ArgumentError, "HTTP method cannot be nil!" unless method
118
+
119
+ method.to_s.upcase
120
+ end.uniq
121
+ end
122
+
123
+ def parse_reference(path)
124
+ reference = Protocol::URL::Reference[path]
125
+ return if reference&.fragment?
126
+
127
+ return reference
128
+ rescue ArgumentError
129
+ nil
130
+ end
131
+
132
+ def parse_query(reference)
133
+ reference.parse_query! || EMPTY_PARAMETERS
134
+ rescue ArgumentError
135
+ nil
136
+ end
137
+ end
138
+ end
@@ -5,5 +5,5 @@
5
5
 
6
6
  # @namespace
7
7
  module Lively
8
- VERSION = "0.18.0"
8
+ VERSION = "0.19.0"
9
9
  end
data/lib/lively.rb CHANGED
@@ -5,6 +5,8 @@
5
5
 
6
6
  require_relative "lively/version"
7
7
 
8
+ require_relative "lively/page"
9
+ require_relative "lively/router"
8
10
  require_relative "lively/assets"
9
11
  require_relative "lively/application"
10
12
 
data/readme.md CHANGED
@@ -20,6 +20,10 @@ Please see the [project documentation](https://socketry.github.io/lively/) for m
20
20
 
21
21
  Please see the [project releases](https://socketry.github.io/lively/releases/index) for all releases.
22
22
 
23
+ ### v0.18.0
24
+
25
+ - Add support for HTTY.
26
+
23
27
  ### v0.17.0
24
28
 
25
29
  - Expose shared application state via `Application[..., controller: Controller.new]`.
@@ -57,13 +61,6 @@ Please see the [project releases](https://socketry.github.io/lively/releases/ind
57
61
 
58
62
  - Tidied up gem dependencies.
59
63
 
60
- ### v0.13.0
61
-
62
- - Added `live-audio` support for background and positional audio in applications.
63
- - Added game audio example demonstrating audio playback.
64
- - Fixed serving non-existent asset paths gracefully.
65
- - Achieved 100% test and documentation coverage.
66
-
67
64
  ## See Also
68
65
 
69
66
  - [live](https://github.com/socketry/live) — Provides client-server communication using websockets.
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.18.0
4
+
5
+ - Add support for HTTY.
6
+
3
7
  ## v0.17.0
4
8
 
5
9
  - Expose shared application state via `Application[..., controller: Controller.new]`.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lively
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0.18'
125
+ - !ruby/object:Gem::Dependency
126
+ name: protocol-url
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.18'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.18'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: xrb
127
141
  requirement: !ruby/object:Gem::Requirement
@@ -155,9 +169,11 @@ files:
155
169
  - lib/lively/environment/htty.rb
156
170
  - lib/lively/environment/middleware.rb
157
171
  - lib/lively/hello_world.rb
172
+ - lib/lively/page.rb
173
+ - lib/lively/page.xrb
158
174
  - lib/lively/pages/index.rb
159
- - lib/lively/pages/index.xrb
160
175
  - lib/lively/resolver.rb
176
+ - lib/lively/router.rb
161
177
  - lib/lively/version.rb
162
178
  - license.md
163
179
  - public/_components/@socketry/live-audio/Live/Audio.js
@@ -203,7 +219,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
203
219
  - !ruby/object:Gem::Version
204
220
  version: '0'
205
221
  requirements: []
206
- rubygems_version: 4.0.6
222
+ rubygems_version: 4.0.10
207
223
  specification_version: 4
208
224
  summary: A simple client-server SPA framework.
209
225
  test_files: []
metadata.gz.sig CHANGED
@@ -1,5 +1 @@
1
- ��� �&fQskӪ�P7y�ز���Aw��1aD)d����@L;73�P&3u�hW,{4�����w��.�r��� [��//̒}7'�Ӈd'"?ɨ4Wgs�ub�u|t3��b�@j��Đ�0y3��F,��}��|����[@k���`%u\�0d�L��WX��l{50���Ԥ���#-�d�`�J���>���M��`�-.��Th��=�D"�c��m������D�`Z�S�Dzȴ�Ku����&�����
2
- pլ�U �*M�\Z��)�X�N�U�_Z�Rj��7�D^���×lb�� �)��p�8P򄺺
3
- ��
4
- W�����jA����D���F��0���,��
5
- ߻�W|؛~�zV�w̤�m���C
1
+ RgD��O�(���>W�`C=W`�R��T:=�����*-��y�;�}@l��!>K��nK��_�@*�E���B�����r��aO9��o=�cx�������zl'Hm(�/�*@�o����K��ל�T��`UOӕ�zH��u�_t�����x��F�֥�<;u4x2��G;��Es��`��V�S�۾��Yw7pZ�D����Z����
@@ -1,29 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>#{self.title}</title>
5
-
6
- <meta charset="UTF-8" />
7
- <meta name="viewport" content="width=device-width, initial-scale=1" />
8
-
9
- <link rel="icon" type="image/png" href="/_static/icon.png" />
10
- <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
11
- <link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
12
-
13
- <script type="importmap">
14
- {
15
- "imports": {
16
- "live": "/_components/@socketry/live/Live.js",
17
- "live-audio": "/_components/@socketry/live-audio/Live/Audio.js",
18
- "morphdom": "/_components/morphdom/morphdom-esm.js"
19
- }
20
- }
21
- </script>
22
-
23
- <script type="module" src="/application.js"></script>
24
- </head>
25
-
26
- <body>
27
- #{self.body&.to_html || "No body specified!"}
28
- </body>
29
- </html>