inertia_hanami 0.1.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/CHANGELOG.md +18 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/LICENSE.txt +21 -0
  6. data/PLAN.md +193 -0
  7. data/README.md +417 -0
  8. data/Rakefile +16 -0
  9. data/lib/generators/inertia_hanami/install_generator.rb +216 -0
  10. data/lib/generators/inertia_hanami/templates/helpers.rb.erb +12 -0
  11. data/lib/generators/inertia_hanami/templates/layout.html.erb.erb +15 -0
  12. data/lib/generators/inertia_hanami/templates/provider.rb.erb +5 -0
  13. data/lib/generators/inertia_hanami/templates/sample_action.rb.erb +15 -0
  14. data/lib/generators/inertia_hanami/templates/sample_template.html.erb.erb +1 -0
  15. data/lib/generators/inertia_hanami/templates/sample_view.rb.erb +13 -0
  16. data/lib/inertia_hanami/action.rb +214 -0
  17. data/lib/inertia_hanami/asset_version.rb +23 -0
  18. data/lib/inertia_hanami/cli/commands/install.rb +29 -0
  19. data/lib/inertia_hanami/cli.rb +16 -0
  20. data/lib/inertia_hanami/configuration.rb +22 -0
  21. data/lib/inertia_hanami/helper.rb +50 -0
  22. data/lib/inertia_hanami/middleware/csrf.rb +63 -0
  23. data/lib/inertia_hanami/middleware/redirects.rb +67 -0
  24. data/lib/inertia_hanami/middleware/version.rb +37 -0
  25. data/lib/inertia_hanami/prop_evaluator.rb +32 -0
  26. data/lib/inertia_hanami/props.rb +68 -0
  27. data/lib/inertia_hanami/protocol_builder.rb +194 -0
  28. data/lib/inertia_hanami/provider.rb +33 -0
  29. data/lib/inertia_hanami/renderer.rb +92 -0
  30. data/lib/inertia_hanami/request_context.rb +75 -0
  31. data/lib/inertia_hanami/ssr_renderer.rb +66 -0
  32. data/lib/inertia_hanami/testing/rspec.rb +265 -0
  33. data/lib/inertia_hanami/version.rb +5 -0
  34. data/lib/inertia_hanami.rb +24 -0
  35. data/sig/inertia_hanami.rbs +4 -0
  36. metadata +190 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaHanami
4
+ # Decides which props actually go into an Inertia response: applies the
5
+ # partial-reload algorithm (only/except/reset dot-paths), the AlwaysProp
6
+ # bypass, the Optional/Defer exclusion-by-default rule, and Once-prop
7
+ # caching, then collects the deferredProps/mergeProps/prependProps/
8
+ # deepMergeProps/matchPropsOn/onceProps/scrollProps metadata the client
9
+ # needs.
10
+ #
11
+ # Operates on the already-evaluated output of PropEvaluator (a Hash whose
12
+ # leaves are plain values or Props::Base instances with resolved,
13
+ # argument-free blocks) plus plain Ruby values for the partial-reload
14
+ # headers, so it stays framework/header-parsing free.
15
+ #
16
+ # Ported from inertia-rails' props_resolver.rb algorithm (inertia-rage's
17
+ # protocol_builder.rb, the originally intended porting source, isn't
18
+ # available as a dependency here), adapted to this repo's Data-based Props
19
+ # wrappers.
20
+ # rubocop:disable Metrics/ClassLength -- one small dedicated transform_* method per prop type
21
+ class ProtocolBuilder
22
+ # Sentinel returned by #transform to signal a filtered-out prop, so a
23
+ # Hash node can tell "excluded" apart from a legitimately nil value.
24
+ DROP = Object.new.freeze
25
+ private_constant :DROP
26
+
27
+ # `partial` bundles the X-Inertia-Partial-* / X-Inertia-Reset /
28
+ # X-Inertia-Except-Once-Props / X-Inertia-Infinite-Scroll-Merge-Intent
29
+ # header values (already split into arrays by the caller, except
30
+ # :scroll_intent which is a single raw value): :component, :only,
31
+ # :except, :reset, :except_once, :scroll_intent.
32
+ def initialize(component:, props:, partial: {})
33
+ @component = component
34
+ @props = props
35
+ assign_partial(partial)
36
+ @deferred_props = Hash.new { |hash, key| hash[key] = [] }
37
+ @merge_props = []
38
+ @prepend_props = []
39
+ @deep_merge_props = []
40
+ @match_props_on = []
41
+ @once_props = {}
42
+ @scroll_props = {}
43
+ end
44
+
45
+ def call
46
+ {
47
+ props: transform(@props, []),
48
+ deferredProps: presence(@deferred_props.transform_values(&:sort)),
49
+ mergeProps: presence(@merge_props),
50
+ prependProps: presence(@prepend_props),
51
+ deepMergeProps: presence(@deep_merge_props),
52
+ matchPropsOn: presence(@match_props_on),
53
+ onceProps: presence(@once_props),
54
+ scrollProps: presence(@scroll_props)
55
+ }.compact
56
+ end
57
+
58
+ private
59
+
60
+ def assign_partial(partial)
61
+ @partial = !partial[:component].nil? && partial[:component] == @component
62
+ @only = partial[:only] || []
63
+ @except = partial[:except] || []
64
+ @reset = partial[:reset] || []
65
+ @except_once = partial[:except_once] || []
66
+ @scroll_intent = partial[:scroll_intent] || "append"
67
+ end
68
+
69
+ # Maps a node's class to the transform method that handles it, so
70
+ # #transform stays a flat dispatch instead of an if/elsif chain.
71
+ TRANSFORMERS = {
72
+ Props::Once => :transform_once,
73
+ Props::Scroll => :transform_scroll,
74
+ Props::Merge => :transform_merge,
75
+ Props::Defer => :transform_defer,
76
+ Props::Optional => :transform_optional
77
+ }.freeze
78
+ private_constant :TRANSFORMERS
79
+
80
+ def transform(node, path)
81
+ return transform_hash(node, path) if node.is_a?(Hash)
82
+ return node.resolve if node.is_a?(Props::Always)
83
+
84
+ transformer = TRANSFORMERS[node.class]
85
+ return send(transformer, node, path) if transformer
86
+
87
+ transform_plain(node, path)
88
+ end
89
+
90
+ def transform_hash(node, path)
91
+ node.each_with_object({}) do |(key, value), acc|
92
+ resolved = transform(value, path + [key.to_s])
93
+ acc[key] = resolved unless resolved == DROP
94
+ end
95
+ end
96
+
97
+ def transform_plain(node, path)
98
+ keep_prop?(path) ? node : DROP
99
+ end
100
+
101
+ def transform_optional(node, path)
102
+ keep_default_excluded?(path) ? node.resolve : DROP
103
+ end
104
+
105
+ def transform_defer(node, path)
106
+ return node.resolve if keep_default_excluded?(path)
107
+
108
+ @deferred_props[node.group] << path.join(".")
109
+ DROP
110
+ end
111
+
112
+ def transform_merge(node, path)
113
+ return DROP unless keep_prop?(path)
114
+
115
+ dot_path = path.join(".")
116
+ (node.deep_merge ? @deep_merge_props : @merge_props) << dot_path
117
+ @match_props_on << "#{dot_path}.#{node.match_on}" if node.match_on
118
+ node.resolve
119
+ end
120
+
121
+ def transform_once(node, path)
122
+ dot_path = path.join(".")
123
+ key = node.key || dot_path
124
+ return DROP if once_cache_hit?(node, dot_path, key)
125
+ return DROP unless keep_prop?(path)
126
+
127
+ @once_props[key] = { "prop" => dot_path, "expiresAt" => node.expires_at }.compact
128
+ node.resolve
129
+ end
130
+
131
+ def once_cache_hit?(node, dot_path, key)
132
+ reset_requested = @reset.include?(dot_path) || @reset.include?(key)
133
+ already_cached = !node.fresh && (@except_once.include?(key) || @except_once.include?(dot_path))
134
+ !reset_requested && already_cached
135
+ end
136
+
137
+ def transform_scroll(node, path)
138
+ return DROP unless keep_prop?(path)
139
+
140
+ dot_path = path.join(".")
141
+ (@scroll_intent == "prepend" ? @prepend_props : @merge_props) << dot_path
142
+ @match_props_on << "#{dot_path}.#{node.match_on}" if node.match_on
143
+ @scroll_props[dot_path] = scroll_metadata(node, dot_path)
144
+ node.resolve
145
+ end
146
+
147
+ def scroll_metadata(node, dot_path)
148
+ {
149
+ "pageName" => node.page_name,
150
+ "previousPage" => node.previous_page,
151
+ "nextPage" => node.next_page,
152
+ "currentPage" => node.current_page,
153
+ "reset" => @reset.include?(dot_path)
154
+ }
155
+ end
156
+
157
+ # Optional/Defer: only included when a partial reload explicitly names
158
+ # them via `only`, subject to `except` on top.
159
+ def keep_default_excluded?(path)
160
+ return false unless @partial
161
+ return false if @only.empty? || !path_matches?(path, @only)
162
+
163
+ !excluded_by_except?(path)
164
+ end
165
+
166
+ # Plain values/Merge/Once: excluded by `except`, or by a non-empty
167
+ # `only` during a partial reload that doesn't match this path.
168
+ def keep_prop?(path)
169
+ return false if excluded_by_except?(path)
170
+ return true unless @partial && !@only.empty?
171
+
172
+ path_matches?(path, @only)
173
+ end
174
+
175
+ def excluded_by_except?(path)
176
+ @partial && !@except.empty? && path_matches?(path, @except)
177
+ end
178
+
179
+ # A dot-path matches a set entry if it's equal to it, a descendant of it
180
+ # (the entry names an ancestor group), or an ancestor of it (walking
181
+ # through an intermediate node on the way to a deeper match).
182
+ def path_matches?(path, set)
183
+ dot_path = path.join(".")
184
+ set.any? do |entry|
185
+ entry == dot_path || dot_path.start_with?("#{entry}.") || entry.start_with?("#{dot_path}.")
186
+ end
187
+ end
188
+
189
+ def presence(collection)
190
+ collection.nil? || collection.empty? ? nil : collection
191
+ end
192
+ end
193
+ # rubocop:enable Metrics/ClassLength
194
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami"
4
+ require "dry/system"
5
+ require "hanami/provider/source"
6
+
7
+ module InertiaHanami
8
+ class Provider < Hanami::Provider::Source
9
+ def prepare
10
+ require "inertia_hanami/configuration"
11
+ require "inertia_hanami/asset_version"
12
+ end
13
+
14
+ def start
15
+ configuration = InertiaHanami::Configuration.new
16
+
17
+ if configuration.config.version.nil?
18
+ configuration.config.version = InertiaHanami::AssetVersion.digest(assets_root)
19
+ end
20
+
21
+ register("config", configuration.config)
22
+ end
23
+
24
+ private
25
+
26
+ def assets_root
27
+ return nil unless target.container.providers.key?(:assets)
28
+
29
+ target.start(:assets)
30
+ target[:assets].root
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module InertiaHanami
6
+ # Builds the Inertia page envelope and decides whether to answer an
7
+ # Inertia XHR request directly (JSON body) or to hand the page off to
8
+ # Hanami's normal rendering pipeline for the initial full-page load
9
+ # (HTML, via the layout's `inertia_root(page:)` helper).
10
+ #
11
+ # Expects `props` to already be evaluated (see PropEvaluator) - this class
12
+ # only applies the partial-reload/prop-filtering algorithm (via
13
+ # ProtocolBuilder) and shapes the resulting envelope.
14
+ class Renderer
15
+ def initialize(request:, response:, component:, props: {}, url: nil, version: nil,
16
+ encrypt_history: false, clear_history: false)
17
+ @request = request
18
+ @response = response
19
+ @component = component
20
+ @props = props
21
+ @url = url || request.fullpath
22
+ @version = version
23
+ @encrypt_history = encrypt_history
24
+ @clear_history = clear_history
25
+ @request_context = RequestContext.new(request.env)
26
+ end
27
+
28
+ def render
29
+ request_context.inertia? ? render_inertia_response : render_initial_load
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :request_context
35
+
36
+ def render_inertia_response
37
+ @response.headers["X-Inertia"] = "true"
38
+ @response.format = :json
39
+ @response.body = page.to_json
40
+ end
41
+
42
+ def render_initial_load
43
+ if ssr_enabled?
44
+ result = SSRRenderer.instance.call(page)
45
+ if result
46
+ @response[:ssr_head] = result.head
47
+ @response[:ssr_body] = result.body
48
+ return
49
+ end
50
+ end
51
+
52
+ @response[:page] = page
53
+ end
54
+
55
+ def ssr_enabled?
56
+ Hanami.app["inertia.config"].ssr.enabled
57
+ end
58
+
59
+ def page
60
+ @page ||= build_page
61
+ end
62
+
63
+ def build_page
64
+ resolved = ProtocolBuilder.new(
65
+ component: @component,
66
+ props: @props,
67
+ partial: request_context.partial_params
68
+ ).call
69
+
70
+ {
71
+ "component" => @component,
72
+ "props" => resolved[:props],
73
+ "url" => @url,
74
+ "version" => @version,
75
+ "encryptHistory" => @encrypt_history,
76
+ "clearHistory" => @clear_history
77
+ }.merge(metadata(resolved))
78
+ end
79
+
80
+ def metadata(resolved)
81
+ {
82
+ "deferredProps" => resolved[:deferredProps],
83
+ "mergeProps" => resolved[:mergeProps],
84
+ "prependProps" => resolved[:prependProps],
85
+ "deepMergeProps" => resolved[:deepMergeProps],
86
+ "matchPropsOn" => resolved[:matchPropsOn],
87
+ "onceProps" => resolved[:onceProps],
88
+ "scrollProps" => resolved[:scrollProps]
89
+ }.compact
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaHanami
4
+ # Parses the Inertia protocol's request headers off a Rack env: detection
5
+ # (X-Inertia), the asset version the client has cached (X-Inertia-Version),
6
+ # the partial-reload headers (X-Inertia-Partial-Component,
7
+ # X-Inertia-Partial-Data, X-Inertia-Partial-Except, X-Inertia-Reset,
8
+ # X-Inertia-Except-Once-Props), and the infinite-scroll merge-intent header
9
+ # (X-Inertia-Infinite-Scroll-Merge-Intent).
10
+ #
11
+ # Stays framework/Hanami-request free (plain Rack env in, plain values out)
12
+ # so it can be constructed from anywhere a Rack env is available.
13
+ class RequestContext
14
+ def initialize(env)
15
+ @env = env
16
+ end
17
+
18
+ def inertia?
19
+ @env["HTTP_X_INERTIA"] == "true"
20
+ end
21
+
22
+ def version
23
+ @env["HTTP_X_INERTIA_VERSION"]
24
+ end
25
+
26
+ def partial_component
27
+ @env["HTTP_X_INERTIA_PARTIAL_COMPONENT"]
28
+ end
29
+
30
+ def partial?
31
+ !partial_component.nil?
32
+ end
33
+
34
+ def partial_only
35
+ split_header("HTTP_X_INERTIA_PARTIAL_DATA")
36
+ end
37
+
38
+ def partial_except
39
+ split_header("HTTP_X_INERTIA_PARTIAL_EXCEPT")
40
+ end
41
+
42
+ def reset
43
+ split_header("HTTP_X_INERTIA_RESET")
44
+ end
45
+
46
+ def except_once
47
+ split_header("HTTP_X_INERTIA_EXCEPT_ONCE_PROPS")
48
+ end
49
+
50
+ def scroll_intent
51
+ @env["HTTP_X_INERTIA_INFINITE_SCROLL_MERGE_INTENT"]
52
+ end
53
+
54
+ # Shaped to feed directly into ProtocolBuilder.new(partial: ...).
55
+ def partial_params
56
+ {
57
+ component: partial_component,
58
+ only: partial_only,
59
+ except: partial_except,
60
+ reset: reset,
61
+ except_once: except_once,
62
+ scroll_intent: scroll_intent
63
+ }
64
+ end
65
+
66
+ private
67
+
68
+ def split_header(key)
69
+ value = @env[key]
70
+ return [] if value.nil? || value.empty?
71
+
72
+ value.split(",").map(&:strip).reject(&:empty?)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "digest"
6
+ require "json"
7
+
8
+ module InertiaHanami
9
+ # POSTs the Inertia page envelope to a separately-run Node SSR server and
10
+ # splices the returned markup into the layout in place of the CSR div
11
+ # (see Helper#inertia_ssr_head / #inertia_ssr_body).
12
+ #
13
+ # Responses are memoized in-process, keyed by a SHA256 digest of the page
14
+ # JSON, so repeat renders of an unchanged page skip the HTTP round-trip.
15
+ # The cache is a plain in-memory Hash - it is per-process and unbounded,
16
+ # not shared across app instances.
17
+ #
18
+ # On any failure (connection error, non-2xx response, malformed JSON),
19
+ # #call returns nil so the caller can fall back to CSR, unless
20
+ # `ssr.raise_on_error` is enabled, in which case the error propagates.
21
+ class SSRRenderer
22
+ Result = Struct.new(:head, :body)
23
+
24
+ def self.instance
25
+ @instance ||= new
26
+ end
27
+
28
+ def initialize(config: Hanami.app["inertia.config"])
29
+ @config = config
30
+ @cache = {}
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ def call(page)
35
+ json = page.to_json
36
+ digest = Digest::SHA256.hexdigest(json)
37
+
38
+ @mutex.synchronize { @cache[digest] } || fetch(json, digest)
39
+ end
40
+
41
+ private
42
+
43
+ def fetch(json, digest)
44
+ result = Result.new(*parse(post(json)).values_at("head", "body"))
45
+ result.head = Array(result.head).join("\n") if result.head.is_a?(Array)
46
+ @mutex.synchronize { @cache[digest] = result }
47
+ result
48
+ rescue StandardError => e
49
+ raise e if @config.ssr.raise_on_error
50
+
51
+ nil
52
+ end
53
+
54
+ def post(json)
55
+ uri = URI.join(@config.ssr.url, "/render")
56
+ response = Net::HTTP.post(uri, json, "Content-Type" => "application/json")
57
+ raise "SSR server responded with #{response.code}" unless response.is_a?(Net::HTTPSuccess)
58
+
59
+ response.body
60
+ end
61
+
62
+ def parse(body)
63
+ JSON.parse(body)
64
+ end
65
+ end
66
+ end