inertia-rage 0.0.1
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/.rspec +1 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/Rakefile +12 -0
- data/lib/inertia/configuration.rb +110 -0
- data/lib/inertia/controller_helpers.rb +159 -0
- data/lib/inertia/frontend.rb +161 -0
- data/lib/inertia/inertia.rb +87 -0
- data/lib/inertia/middleware/assets.rb +40 -0
- data/lib/inertia/middleware/version.rb +36 -0
- data/lib/inertia/props.rb +31 -0
- data/lib/inertia/protocol_builder.rb +209 -0
- data/lib/inertia/rage_extension.rb +48 -0
- data/lib/inertia/renderer.rb +69 -0
- data/lib/inertia/request_context.rb +85 -0
- data/lib/inertia/rspec.rb +171 -0
- data/lib/inertia/version.rb +5 -0
- data/lib/inertia/vite_dev_server.rb +27 -0
- data/lib/inertia-rage.rb +1 -0
- metadata +77 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module Inertia
|
|
7
|
+
##
|
|
8
|
+
# Manages frontend asset integration for Inertia responses.
|
|
9
|
+
#
|
|
10
|
+
class Frontend
|
|
11
|
+
class << self
|
|
12
|
+
# Returns the root directory of the frontend application.
|
|
13
|
+
#
|
|
14
|
+
# Uses {Configuration#frontend_path} if set, otherwise searches for a
|
|
15
|
+
# Vite config file in common locations to determine where the frontend
|
|
16
|
+
# source lives.
|
|
17
|
+
#
|
|
18
|
+
# @return [Pathname] path to the frontend root directory
|
|
19
|
+
# @raise [RuntimeError] if no Vite config file is found
|
|
20
|
+
def root
|
|
21
|
+
@root ||= Inertia.config.frontend_path || begin
|
|
22
|
+
vite_config = Rage.root.glob(["*/vite.config.{js,ts,mjs,mts}", "app/*/vite.config.{js,ts,mjs,mts}"]).first
|
|
23
|
+
raise "Vite config not found" unless vite_config
|
|
24
|
+
|
|
25
|
+
vite_config.dirname
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the directory containing built frontend assets.
|
|
30
|
+
#
|
|
31
|
+
# Uses {Configuration#build_path} if set, otherwise defaults to the
|
|
32
|
+
# `dist` directory inside the frontend root.
|
|
33
|
+
#
|
|
34
|
+
# @return [Pathname] path to the build output directory
|
|
35
|
+
# @raise [RuntimeError] if no Vite config file is found
|
|
36
|
+
def dist
|
|
37
|
+
@dist ||= Inertia.config.build_path || root.join("dist")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns a version identifier for the frontend assets.
|
|
41
|
+
#
|
|
42
|
+
# Computes an MD5 hash of the Vite manifest or index.html to detect
|
|
43
|
+
# when assets have changed, enabling Inertia's asset versioning.
|
|
44
|
+
#
|
|
45
|
+
# @return [String] MD5 hex digest of the manifest file
|
|
46
|
+
# @raise [RuntimeError] if no Vite config file is found
|
|
47
|
+
def version
|
|
48
|
+
@version ||= begin
|
|
49
|
+
manifest = dist.glob([".vite/manifest.json", "index.html"]).first
|
|
50
|
+
Digest::MD5.file(manifest.to_s).hexdigest if manifest
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the command prefix for executing npm packages.
|
|
55
|
+
#
|
|
56
|
+
# Detects the package manager by checking for lock files and returns
|
|
57
|
+
# the appropriate command to run package binaries.
|
|
58
|
+
#
|
|
59
|
+
# @return [String] command prefix (e.g., "npx", "pnpm exec", "yarn")
|
|
60
|
+
# @raise [RuntimeError] if no supported package manager is detected
|
|
61
|
+
def package_runner
|
|
62
|
+
@package_runner ||= if root.join("package-lock.json").exist?
|
|
63
|
+
"npx"
|
|
64
|
+
elsif root.join("pnpm-lock.yaml").exist?
|
|
65
|
+
"pnpm exec"
|
|
66
|
+
elsif root.join("bun.lockb").exist? || root.join("bun.lock").exist?
|
|
67
|
+
"bun x --bun"
|
|
68
|
+
elsif root.join("yarn.lock").exist?
|
|
69
|
+
"yarn"
|
|
70
|
+
elsif root.join("deno.lock").exist?
|
|
71
|
+
"deno x"
|
|
72
|
+
else
|
|
73
|
+
raise "No supported package manager detected"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@package_runner
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Renders the HTML layout with the Inertia page object embedded.
|
|
80
|
+
#
|
|
81
|
+
# In development, it fetches the layout from the Vite dev server and
|
|
82
|
+
# rewrites relative asset paths to absolute URLs. In production, it
|
|
83
|
+
# reads the pre-built layout from disk and caches it.
|
|
84
|
+
#
|
|
85
|
+
# @param data [Hash] the Inertia page object to embed
|
|
86
|
+
# @return [String] HTML document with page data injected
|
|
87
|
+
def render_layout(data)
|
|
88
|
+
if Rage.env.development?
|
|
89
|
+
build_dynamic_layout(data)
|
|
90
|
+
else
|
|
91
|
+
build_static_layout(data)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Fetches the layout from Vite dev server and rewrites asset URLs.
|
|
98
|
+
#
|
|
99
|
+
# Transforms relative paths in src/href attributes and ES module imports
|
|
100
|
+
# to point to the Vite dev server.
|
|
101
|
+
#
|
|
102
|
+
# @param data [Hash] the Inertia page object to embed
|
|
103
|
+
# @return [String] HTML with rewritten URLs and page data
|
|
104
|
+
def build_dynamic_layout(data)
|
|
105
|
+
config = Inertia.config.dev_server
|
|
106
|
+
dev_server_url = "http://#{config.host}:#{config.port}"
|
|
107
|
+
|
|
108
|
+
retries = 0
|
|
109
|
+
begin
|
|
110
|
+
layout = Net::HTTP.get(URI(dev_server_url))
|
|
111
|
+
rescue Errno::ECONNREFUSED, Errno::EBADF
|
|
112
|
+
raise if (retries += 1) > 5
|
|
113
|
+
sleep 0.2
|
|
114
|
+
retry
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
layout.gsub!(/(src|href)=(["'])\/([^"']+)\2/) do
|
|
118
|
+
"#{$1}=\"#{dev_server_url}/#{$3}\""
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
layout.gsub!(/from\s*(["'])\/([^"']+)\1/) do
|
|
122
|
+
"from \"#{dev_server_url}/#{$2}\""
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
inject_page_data(layout, data)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns the cached static layout with page data.
|
|
129
|
+
#
|
|
130
|
+
# @param data [Hash] the Inertia page object to embed
|
|
131
|
+
# @return [String] HTML with page data injected
|
|
132
|
+
# @raise [RuntimeError] if index.html does not exist in build path
|
|
133
|
+
def build_static_layout(data)
|
|
134
|
+
@layout ||= begin
|
|
135
|
+
layout = dist.join("index.html")
|
|
136
|
+
raise "Production layout not found at #{layout}. Ensure the frontend has been built" unless layout.exist?
|
|
137
|
+
layout.read
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
inject_page_data(@layout, data)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Injects the page object JSON into the HTML body.
|
|
144
|
+
#
|
|
145
|
+
# @param layout [String] the HTML layout template
|
|
146
|
+
# @param data [Hash] the Inertia page object
|
|
147
|
+
# @return [String] HTML with page data script tag inserted after <body>
|
|
148
|
+
def inject_page_data(layout, data)
|
|
149
|
+
json_data = data.to_json
|
|
150
|
+
json_data.gsub!("<", '\u003c') if json_data.include?("<")
|
|
151
|
+
|
|
152
|
+
layout.sub(/<body([^>]*)>/i) do |body_tag|
|
|
153
|
+
<<~HTML
|
|
154
|
+
#{body_tag}
|
|
155
|
+
<script data-page="app" type="application/json">#{json_data}</script>
|
|
156
|
+
HTML
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rage"
|
|
4
|
+
|
|
5
|
+
require_relative "props"
|
|
6
|
+
require_relative "frontend"
|
|
7
|
+
require_relative "renderer"
|
|
8
|
+
require_relative "request_context"
|
|
9
|
+
require_relative "protocol_builder"
|
|
10
|
+
require_relative "controller_helpers"
|
|
11
|
+
require_relative "configuration"
|
|
12
|
+
require_relative "middleware/version"
|
|
13
|
+
require_relative "middleware/assets"
|
|
14
|
+
require_relative "rage_extension"
|
|
15
|
+
require_relative "version"
|
|
16
|
+
|
|
17
|
+
module Inertia
|
|
18
|
+
# Configures the Inertia integration using a block.
|
|
19
|
+
#
|
|
20
|
+
# The block is evaluated in the context of the {Configuration} instance,
|
|
21
|
+
# allowing direct access to configuration setters.
|
|
22
|
+
#
|
|
23
|
+
# @yield Evaluates the block in the configuration context
|
|
24
|
+
# @example
|
|
25
|
+
# Inertia.configure do |config|
|
|
26
|
+
# config.frontend_path = "client"
|
|
27
|
+
# config.build_on_start = false
|
|
28
|
+
#
|
|
29
|
+
# config.dev_server.host = "0.0.0.0"
|
|
30
|
+
# config.dev_server.port = 3000
|
|
31
|
+
# end
|
|
32
|
+
def self.configure
|
|
33
|
+
yield config
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the global configuration instance.
|
|
37
|
+
#
|
|
38
|
+
# @return [Configuration] the configuration object
|
|
39
|
+
def self.config
|
|
40
|
+
@config ||= Configuration.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @!group Props
|
|
44
|
+
# Creates a deferred prop that will be loaded in a subsequent request after the initial page load.
|
|
45
|
+
#
|
|
46
|
+
# @param group [String] the group name for batching deferred props together
|
|
47
|
+
# @yield the block that returns the prop value when evaluated
|
|
48
|
+
# @return [Props::Deferred] a deferred prop wrapper
|
|
49
|
+
# @example
|
|
50
|
+
# render inertia: "Users/Index", props: {
|
|
51
|
+
# users: Inertia.deferred { User.all }
|
|
52
|
+
# }
|
|
53
|
+
def self.deferred(group: "default", &block)
|
|
54
|
+
Props::Deferred.new(group:, block:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Creates a prop that is evaluated only once and cached by the frontend.
|
|
58
|
+
#
|
|
59
|
+
# @param key [String, nil] a unique cache key for the prop (auto-generated if `nil`)
|
|
60
|
+
# @param fresh [Boolean] forces re-evaluation when `true`
|
|
61
|
+
# @param expires_in [Integer, ActiveSupport::Duration, nil] cache expiration time in seconds
|
|
62
|
+
# @yield the block that returns the prop value when evaluated
|
|
63
|
+
# @return [Props::Once] a once-evaluated prop wrapper
|
|
64
|
+
# @example
|
|
65
|
+
# render inertia: "Dashboard", props: {
|
|
66
|
+
# stats: Inertia.once(expires_in: 300) { Stats.calculate }
|
|
67
|
+
# }
|
|
68
|
+
def self.once(key: nil, fresh: false, expires_in: nil, &block)
|
|
69
|
+
Props::Once.new(key:, fresh:, expires_in:, block:)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Creates a prop that is only included when explicitly requested by the client.
|
|
73
|
+
#
|
|
74
|
+
# @yield the block that returns the prop value when evaluated
|
|
75
|
+
# @return [Props::Optional] an optional prop wrapper
|
|
76
|
+
# @example
|
|
77
|
+
# render inertia: "Users/Show", props: {
|
|
78
|
+
# user: user,
|
|
79
|
+
# permissions: Inertia.optional { user.permissions }
|
|
80
|
+
# }
|
|
81
|
+
def self.optional(&block)
|
|
82
|
+
Props::Optional.new(block:)
|
|
83
|
+
end
|
|
84
|
+
# @!endgroup
|
|
85
|
+
|
|
86
|
+
autoload :ViteDevServer, "inertia/vite_dev_server"
|
|
87
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inertia
|
|
4
|
+
module Middleware
|
|
5
|
+
##
|
|
6
|
+
# Serves built Vite assets in production using `x-sendfile` headers.
|
|
7
|
+
#
|
|
8
|
+
class Assets
|
|
9
|
+
EMPTY_BODY = [].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
method = env["REQUEST_METHOD"]
|
|
17
|
+
return @app.call(env) unless method == "GET" || method == "HEAD"
|
|
18
|
+
|
|
19
|
+
raw_path = env["PATH_INFO"]
|
|
20
|
+
return @app.call(env) unless raw_path.start_with?("/assets/")
|
|
21
|
+
|
|
22
|
+
[
|
|
23
|
+
200,
|
|
24
|
+
{
|
|
25
|
+
"x-sendfile" => raw_path.delete_prefix("/assets"),
|
|
26
|
+
"x-sendfile-root" => assets_root,
|
|
27
|
+
"cache-control" => "public, max-age=31536000, immutable"
|
|
28
|
+
},
|
|
29
|
+
EMPTY_BODY
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def assets_root
|
|
36
|
+
@assets_root ||= Frontend.dist.join("assets").realpath.to_s
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inertia
|
|
4
|
+
module Middleware
|
|
5
|
+
##
|
|
6
|
+
# Rack middleware that enforces Inertia asset version consistency.
|
|
7
|
+
#
|
|
8
|
+
# For GET requests carrying `X-Inertia-Version`, compares the client version
|
|
9
|
+
# with {Frontend.version}. A mismatch returns `409` with `X-Inertia-Location`,
|
|
10
|
+
# prompting the client to reload the current URL with the latest assets.
|
|
11
|
+
#
|
|
12
|
+
class Version
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
|
|
19
|
+
|
|
20
|
+
client_version = env["HTTP_X_INERTIA_VERSION"]
|
|
21
|
+
|
|
22
|
+
if client_version.nil? || client_version == server_version
|
|
23
|
+
@app.call(env)
|
|
24
|
+
else
|
|
25
|
+
[409, { "x-inertia-location" => Rack::Request.new(env).url }, []]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def server_version
|
|
32
|
+
@server_version ||= Frontend.version
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inertia
|
|
4
|
+
# Contains prop wrapper classes.
|
|
5
|
+
module Props
|
|
6
|
+
# Base class for all prop wrappers.
|
|
7
|
+
class Base < Data
|
|
8
|
+
def resolve
|
|
9
|
+
block.call
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# A prop that is loaded in a subsequent request after the initial page load.
|
|
14
|
+
Deferred = Base.define(:group, :block)
|
|
15
|
+
|
|
16
|
+
# A prop that is evaluated only once.
|
|
17
|
+
Once = Base.define(:key, :fresh, :expires_in, :block) do
|
|
18
|
+
# Calculates the expiration timestamp in milliseconds.
|
|
19
|
+
#
|
|
20
|
+
# @return [Integer, nil] the expiration time as Unix timestamp in milliseconds, or nil if no expiration
|
|
21
|
+
def expires_at
|
|
22
|
+
return unless expires_in
|
|
23
|
+
|
|
24
|
+
((Time.now + expires_in).to_f * 1_000).to_i
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A prop that is only included in the response when explicitly requested by the client.
|
|
29
|
+
Optional = Base.define(:block)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inertia
|
|
4
|
+
##
|
|
5
|
+
# Builds the Inertia page object for a rendered component.
|
|
6
|
+
#
|
|
7
|
+
# ProtocolBuilder resolves the server-side props hash into the shape expected
|
|
8
|
+
# by the Inertia protocol. It applies partial reload filtering, evaluates lazy
|
|
9
|
+
# values, walks nested hashes and arrays, and collects protocol metadata such
|
|
10
|
+
# as `deferredProps` and `onceProps`.
|
|
11
|
+
#
|
|
12
|
+
# Nested prop paths are tracked with dot notation, e.g. `auth.user.name`, so
|
|
13
|
+
# request headers can target either a top-level prop or a nested value.
|
|
14
|
+
#
|
|
15
|
+
class ProtocolBuilder
|
|
16
|
+
# Internal marker returned by resolver methods when a prop should be
|
|
17
|
+
# left out of the page object.
|
|
18
|
+
OMIT = Object.new
|
|
19
|
+
private_constant :OMIT
|
|
20
|
+
|
|
21
|
+
# Page props should contain an `errors` object that defaults to {}
|
|
22
|
+
EMPTY_ERRORS = {}.freeze
|
|
23
|
+
private_constant :EMPTY_ERRORS
|
|
24
|
+
|
|
25
|
+
# Creates a new protocol builder.
|
|
26
|
+
#
|
|
27
|
+
# @param component [String] the Inertia component name being rendered
|
|
28
|
+
# @param props [Hash] the props provided by the controller
|
|
29
|
+
# @param context [RequestContext] request-specific partial reload context
|
|
30
|
+
def initialize(component, props, context:)
|
|
31
|
+
@props = props
|
|
32
|
+
@context = context
|
|
33
|
+
|
|
34
|
+
@response = { component:, url: context.url, version: Frontend.version }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Builds the page object hash.
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] the Inertia page object payload
|
|
40
|
+
def call
|
|
41
|
+
props = resolve_props
|
|
42
|
+
props[:errors] = EMPTY_ERRORS unless props.has_key?(:errors)
|
|
43
|
+
|
|
44
|
+
@response[:props] = props
|
|
45
|
+
@response[:deferredProps] = @deferred_props_metadata if @deferred_props_metadata
|
|
46
|
+
@response[:onceProps] = @once_props_metadata if @once_props_metadata
|
|
47
|
+
|
|
48
|
+
@response
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Resolves a hash of props into protocol-ready response props.
|
|
54
|
+
#
|
|
55
|
+
# `parent_was_resolved` is set when traversing a hash or array returned by a
|
|
56
|
+
# lazy prop. In that case, the parent prop was already selected for this
|
|
57
|
+
# response, so partial reload `only` filtering should not prune children from
|
|
58
|
+
# the computed value.
|
|
59
|
+
#
|
|
60
|
+
# This flag does not mean every child was explicitly requested. Deferred and
|
|
61
|
+
# optional props inside the computed value are included on matching partial
|
|
62
|
+
# reloads, but remain deferred/omitted on initial loads. Once props still use
|
|
63
|
+
# their own path/key for cache exclusion, so resolving a parent does not by
|
|
64
|
+
# itself bypass `X-Inertia-Except-Once-Props`.
|
|
65
|
+
#
|
|
66
|
+
# @param props [Hash] props to resolve at the current nesting level
|
|
67
|
+
# @param path [String, nil] dot-notated path prefix for nested props
|
|
68
|
+
# @param parent_was_resolved [Boolean] whether this level came from a lazy prop
|
|
69
|
+
# @return [Hash] resolved props for this nesting level
|
|
70
|
+
def resolve_props(props = @props, path: nil, parent_was_resolved: false)
|
|
71
|
+
props.each_with_object({}) do |(prop_name, prop), response|
|
|
72
|
+
prop_name_with_path = prop_with_path(prop_name, path)
|
|
73
|
+
|
|
74
|
+
prop_status = @context.prop_status(prop_name_with_path)
|
|
75
|
+
next if prop_status == :excluded && !parent_was_resolved
|
|
76
|
+
|
|
77
|
+
is_explicitly_requested = (prop_status == :requested)
|
|
78
|
+
should_include_from_resolved_parent = parent_was_resolved && @context.partial_render?
|
|
79
|
+
should_include_on_demand_prop = is_explicitly_requested || should_include_from_resolved_parent
|
|
80
|
+
|
|
81
|
+
resolved = if prop.respond_to?(:call)
|
|
82
|
+
# lazy props
|
|
83
|
+
resolve_lazy_prop(prop, path: prop_name_with_path)
|
|
84
|
+
|
|
85
|
+
elsif prop.is_a?(Inertia::Props::Deferred)
|
|
86
|
+
if should_include_on_demand_prop
|
|
87
|
+
# the deferred prop was requested
|
|
88
|
+
prop.resolve
|
|
89
|
+
else
|
|
90
|
+
# let the client know there're deferred props
|
|
91
|
+
deferred_props_metadata[prop.group] << prop_name_with_path
|
|
92
|
+
OMIT
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
elsif prop.is_a?(Inertia::Props::Once)
|
|
96
|
+
key = prop.key || prop_name_with_path
|
|
97
|
+
|
|
98
|
+
# include metadata unless explicitly excluded (`parent_was_resolved` path)
|
|
99
|
+
once_props_metadata[key] = { prop: prop_name_with_path, expiresAt: prop.expires_at } unless prop_status == :excluded
|
|
100
|
+
# resolve the prop value only if requested
|
|
101
|
+
is_explicitly_requested || prop.fresh || !@context.once_prop_excluded?(key) ? prop.resolve : OMIT
|
|
102
|
+
|
|
103
|
+
elsif prop.is_a?(Inertia::Props::Optional)
|
|
104
|
+
# excluded on initial page load
|
|
105
|
+
should_include_on_demand_prop ? prop.resolve : OMIT
|
|
106
|
+
|
|
107
|
+
elsif prop.is_a?(Hash)
|
|
108
|
+
# nested hash props
|
|
109
|
+
if prop.empty?
|
|
110
|
+
prop
|
|
111
|
+
else
|
|
112
|
+
nested_prop = resolve_props(prop, path: prop_name_with_path, parent_was_resolved:)
|
|
113
|
+
nested_prop.empty? ? OMIT : nested_prop
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
elsif prop.is_a?(Array)
|
|
117
|
+
# nested array props
|
|
118
|
+
resolve_array_prop(prop, path: prop_name_with_path, parent_was_resolved:)
|
|
119
|
+
|
|
120
|
+
else
|
|
121
|
+
# scalar prop
|
|
122
|
+
prop
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# chained props and props returned from lazy props
|
|
126
|
+
if resolved.is_a?(Inertia::Props::Base)
|
|
127
|
+
prop = resolved
|
|
128
|
+
redo
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
response[prop_name] = resolved unless resolved.equal?(OMIT)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Evaluates a lazy prop and recursively resolves nested data it returns.
|
|
136
|
+
#
|
|
137
|
+
# A lazy prop can return a plain scalar value, a hash, or an array. Hashes and
|
|
138
|
+
# arrays are traversed with `parent_was_resolved: true` so nested
|
|
139
|
+
# optional/deferred props follow the resolved-parent rules described in `#resolve_props`.
|
|
140
|
+
#
|
|
141
|
+
# @param prop [#call] lazy value to evaluate
|
|
142
|
+
# @param path [String] dot-notated path of the lazy prop
|
|
143
|
+
# @return [Object] resolved value
|
|
144
|
+
def resolve_lazy_prop(prop, path:)
|
|
145
|
+
resolved = prop.call
|
|
146
|
+
|
|
147
|
+
if resolved.is_a?(Hash)
|
|
148
|
+
resolve_props(resolved, path:, parent_was_resolved: true)
|
|
149
|
+
elsif resolved.is_a?(Array)
|
|
150
|
+
resolve_array_prop(resolved, path:, parent_was_resolved: true)
|
|
151
|
+
else
|
|
152
|
+
resolved
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Resolves arrays that may contain nested hashes or lazy values.
|
|
157
|
+
#
|
|
158
|
+
# Array indices are included in generated paths so metadata can still point
|
|
159
|
+
# to the nested prop location, e.g. `items.0.author`.
|
|
160
|
+
#
|
|
161
|
+
# @param prop [Array] array value to resolve
|
|
162
|
+
# @param path [String] dot-notated path of the array prop
|
|
163
|
+
# @param parent_was_resolved [Boolean] whether the array came from a lazy prop
|
|
164
|
+
# @return [Array] resolved array
|
|
165
|
+
def resolve_array_prop(prop, path:, parent_was_resolved:)
|
|
166
|
+
prop.each_with_index.each_with_object([]) do |(nested_prop, i), response|
|
|
167
|
+
resolved = if nested_prop.is_a?(Hash)
|
|
168
|
+
resolve_props(nested_prop, path: "#{path}.#{i}", parent_was_resolved:)
|
|
169
|
+
elsif nested_prop.respond_to?(:call)
|
|
170
|
+
resolve_lazy_prop(nested_prop, path: "#{path}.#{i}")
|
|
171
|
+
else
|
|
172
|
+
nested_prop
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# drop hash items whose contents were fully filtered out
|
|
176
|
+
next if nested_prop.is_a?(Hash) && !nested_prop.empty? && resolved.empty?
|
|
177
|
+
|
|
178
|
+
response << resolved
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Builds a dot-notated path for a prop at the current nesting level.
|
|
183
|
+
#
|
|
184
|
+
# @param prop_name [String, Symbol] prop key at the current level
|
|
185
|
+
# @param path [String, nil] parent path
|
|
186
|
+
# @return [String] full dot-notated prop path
|
|
187
|
+
def prop_with_path(prop_name, path)
|
|
188
|
+
if path
|
|
189
|
+
"#{path}.#{prop_name}"
|
|
190
|
+
else
|
|
191
|
+
prop_name.to_s
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Lazily initializes deferred prop metadata grouped by deferred group name.
|
|
196
|
+
#
|
|
197
|
+
# @return [Hash{String=>Array<String>}] deferred prop paths by group
|
|
198
|
+
def deferred_props_metadata
|
|
199
|
+
@deferred_props_metadata ||= Hash.new { |h, k| h[k] = [] }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Lazily initializes once prop metadata keyed by once cache key.
|
|
203
|
+
#
|
|
204
|
+
# @return [Hash{String=>Hash}] once prop metadata
|
|
205
|
+
def once_props_metadata
|
|
206
|
+
@once_props_metadata ||= {}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inertia
|
|
4
|
+
##
|
|
5
|
+
# Integrates the gem with Rage - the extension serves as the glue between Inertia
|
|
6
|
+
# and Rage, configuring rendering, asset handling, and controller helpers.
|
|
7
|
+
#
|
|
8
|
+
class RageExtension < Rage::Extension
|
|
9
|
+
configure do
|
|
10
|
+
# Register custom renderer to enable `render inertia: ...` syntax in controllers
|
|
11
|
+
config.renderer(:inertia) do |component, props: {}|
|
|
12
|
+
Renderer.call(component, props, controller: self)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Automatically start Vite dev server in development mode
|
|
16
|
+
config.daemons << ViteDevServer if Rage.env.development?
|
|
17
|
+
|
|
18
|
+
# In production, serve prebuilt static assets via the public file server and Assets middleware
|
|
19
|
+
unless Rage.env.development?
|
|
20
|
+
config.public_file_server.enabled = true
|
|
21
|
+
|
|
22
|
+
config.middleware.allow_outside_request_fiber! do
|
|
23
|
+
config.middleware.insert_before 0, Middleware::Assets
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Enable automatic generation of `new` and `edit` routes via resource helpers
|
|
28
|
+
config.router.form_actions = true
|
|
29
|
+
# Verify asset version consistency between client and server
|
|
30
|
+
config.middleware.use Middleware::Version
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Include ControllerHelpers to add support for `redirect_to` and other Inertia-specific methods
|
|
34
|
+
initializer "inertia.rage.controller_helpers" do
|
|
35
|
+
RageController::API.include ControllerHelpers
|
|
36
|
+
RageController::API.extend ControllerHelpers::ClassMethods
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Prebuild frontend assets before launching the server in production
|
|
40
|
+
before_server_start do
|
|
41
|
+
if Inertia.config.build_on_start?
|
|
42
|
+
puts "INFO: Building frontend"
|
|
43
|
+
puts ""
|
|
44
|
+
system("#{Frontend.package_runner} vite build", chdir: Frontend.root) || abort("ERROR: Frontend build failed")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inertia
|
|
4
|
+
##
|
|
5
|
+
# Renders Inertia responses from controller actions.
|
|
6
|
+
#
|
|
7
|
+
# This class enables the `render inertia:` DSL in controllers:
|
|
8
|
+
#
|
|
9
|
+
# # Explicit component name
|
|
10
|
+
# render inertia: "Users/Index", props: { users: User.all }
|
|
11
|
+
#
|
|
12
|
+
# # Inferred component name
|
|
13
|
+
# render inertia: { users: User.all } # => "UsersController#index" infers "Users/Index"
|
|
14
|
+
#
|
|
15
|
+
# For XHR requests with the `X-Inertia` header, returns a JSON response
|
|
16
|
+
# containing the Inertia page object. For standard requests, delegates to
|
|
17
|
+
# {Frontend.render_layout} to embed the page data in an HTML shell.
|
|
18
|
+
#
|
|
19
|
+
class Renderer
|
|
20
|
+
# Renders an Inertia response for the given component and props.
|
|
21
|
+
#
|
|
22
|
+
# This is the entry point for Inertia's custom rendering. It wires together
|
|
23
|
+
# {RequestContext} (partial reload filtering) and {ProtocolBuilder} (page object
|
|
24
|
+
# construction), and handles both XHR and initial page load responses.
|
|
25
|
+
#
|
|
26
|
+
# @param component [String, Hash] the Inertia component name, or props hash when using component name inference
|
|
27
|
+
# @param props [Hash] the props to pass to the component (ignored when `component` is a Hash)
|
|
28
|
+
# @param controller [RageController::API] the controller instance handling the request
|
|
29
|
+
# @return [String] JSON response for XHR requests, HTML for initial loads
|
|
30
|
+
def self.call(component, props, controller:)
|
|
31
|
+
request, headers = controller.request, controller.headers
|
|
32
|
+
|
|
33
|
+
unless component.is_a?(String)
|
|
34
|
+
raise ArgumentError, "props must be nil when using inferred component names" if props&.any?
|
|
35
|
+
|
|
36
|
+
props = component
|
|
37
|
+
|
|
38
|
+
component_path = controller.class.name.delete_suffix("Controller")
|
|
39
|
+
component_path.gsub!("::", "/")
|
|
40
|
+
action_name = controller.action_name
|
|
41
|
+
component_name = action_name.include?("_") ? action_name.split("_").map!(&:capitalize).join : action_name.capitalize
|
|
42
|
+
|
|
43
|
+
component = "#{component_path}/#{component_name}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if shared_data = controller.inertia_shared_data
|
|
47
|
+
props = shared_data.merge(props)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context = RequestContext.new(request, component:)
|
|
51
|
+
data = ProtocolBuilder.new(component, props, context:).call
|
|
52
|
+
|
|
53
|
+
if headers["vary"]
|
|
54
|
+
headers["vary"] += ", x-inertia"
|
|
55
|
+
else
|
|
56
|
+
headers["vary"] = "x-inertia"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if request.env["HTTP_X_INERTIA"]
|
|
60
|
+
headers["x-inertia"] = "true"
|
|
61
|
+
headers["content-type"] = "application/json; charset=utf-8"
|
|
62
|
+
data.to_json
|
|
63
|
+
else
|
|
64
|
+
headers["content-type"] = "text/html; charset=utf-8"
|
|
65
|
+
Frontend.render_layout(data)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|