sinatra-inertia 0.1.3 → 0.1.5
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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +44 -35
- data/lib/sinatra/inertia/async_sources.rb +2 -2
- data/lib/sinatra/inertia/csrf_middleware.rb +37 -26
- data/lib/sinatra/inertia/deferred.rb +3 -7
- data/lib/sinatra/inertia/helpers.rb +95 -25
- data/lib/sinatra/inertia/middleware.rb +12 -11
- data/lib/sinatra/inertia/response.rb +51 -28
- data/lib/sinatra/inertia/version.rb +1 -1
- data/lib/sinatra/inertia.rb +48 -26
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9172865c13484ad3352d36558c065514b1f3f175fd70ec8ce4f7cde77fbc3f8
|
|
4
|
+
data.tar.gz: f308c6741aed6cf48f7fc0588e7e0e11a7d5bad99b07000a20982fa01ee894b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c4c38a5f2a2a2f16b5f3d449d442a7bd378fb491ae078c67a71543c6501e4412b93fc85751d54a0da0dd907d5f66340602aeef51a0899a990de921c495a5e28
|
|
7
|
+
data.tar.gz: 73fabe7fc1fb6191b1c454785b5450f2889e784ea12890587d203e7ccf216ca1143f51241ab06ea7eff6347bd83fb0c7b5066fb0b95e4ac413af55f0b8cc77ab
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5 — 2026-06-25
|
|
4
|
+
|
|
5
|
+
- Extract the Ruby-way Kagero layer into the separate `sinatra-kagero` gem.
|
|
6
|
+
- Keep `sinatra-inertia` focused on the Inertia v2 wire protocol.
|
|
7
|
+
- Remove Phlex and Literal from the `sinatra-inertia` runtime dependency surface.
|
|
8
|
+
|
|
9
|
+
## 0.1.4 — 2026-05-03
|
|
10
|
+
|
|
11
|
+
- Add the recommended Sinatra-native page API: `render 'Component', props`,
|
|
12
|
+
`share_props`, `set :page_version`, `set :page_layout`, and route helpers
|
|
13
|
+
`defer`, `always`, `optional`, `lazy`, `merge`, `page_errors`,
|
|
14
|
+
`clear_history!`, and `encrypt_history!`.
|
|
15
|
+
- Keep existing `inertia_*` helpers/settings and `Inertia.*` prop wrappers
|
|
16
|
+
working for compatibility.
|
|
17
|
+
|
|
3
18
|
## 0.1.3 — 2026-04-29
|
|
4
19
|
|
|
5
20
|
- `lib/sinatra/inertia/async_sources.rb` registers under
|
data/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A Sinatra extension that implements the full **Inertia.js v2** wire protocol —
|
|
4
4
|
page-object responses, version-mismatch detection, partial reloads,
|
|
5
|
-
deferred / lazy / always / optional / merge
|
|
6
|
-
|
|
5
|
+
deferred / lazy / always / optional / merge props, encrypted history, 303
|
|
6
|
+
redirect promotion, and error/flash session sweeps.
|
|
7
7
|
|
|
8
8
|
Pure Sinatra-compatible: depends only on `sinatra` and `rack`. Runs on
|
|
9
9
|
MRI Ruby and on the [homura](https://github.com/kazuph/homura) Cloudflare
|
|
@@ -13,11 +13,12 @@ Workers + Opal stack.
|
|
|
13
13
|
|
|
14
14
|
Two principles drove the API:
|
|
15
15
|
|
|
16
|
-
1. **
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
2. **Class-level config uses
|
|
20
|
-
|
|
16
|
+
1. **Routes render pages.** Application code should read like ordinary
|
|
17
|
+
Sinatra: `render 'Pages/Show', record: record`. The Inertia protocol stays
|
|
18
|
+
in the extension.
|
|
19
|
+
2. **Class-level config uses Sinatra nouns.** Use `set :page_version`,
|
|
20
|
+
`set :page_layout`, and `share_props do ... end`. The older `inertia_*`
|
|
21
|
+
names still work for existing apps.
|
|
21
22
|
|
|
22
23
|
## Installation
|
|
23
24
|
|
|
@@ -33,10 +34,12 @@ gem 'sinatra-inertia'
|
|
|
33
34
|
require 'sinatra'
|
|
34
35
|
require 'sinatra/inertia'
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
register Sinatra::Inertia
|
|
38
|
+
|
|
39
|
+
set :page_version, -> { ENV.fetch('ASSETS_VERSION', '1') }
|
|
37
40
|
|
|
38
41
|
get '/' do
|
|
39
|
-
|
|
42
|
+
render 'Pages/Hello', name: 'world'
|
|
40
43
|
end
|
|
41
44
|
```
|
|
42
45
|
|
|
@@ -61,42 +64,49 @@ end
|
|
|
61
64
|
|
|
62
65
|
| helper | purpose |
|
|
63
66
|
|---|---|
|
|
64
|
-
| `
|
|
65
|
-
| `render
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
67
|
+
| `render 'Comp', props_hash` | Render an Inertia response (HTML on first hit, JSON on Inertia visit). |
|
|
68
|
+
| `render 'Comp', key: value` | Keyword-prop form of the same page render. |
|
|
69
|
+
| `page_request?` | True when `X-Inertia: true` header is present. |
|
|
70
|
+
| `page_errors(payload = nil)` | Read or write validation errors that survive one redirect. |
|
|
71
|
+
| `clear_history!` | Mark the next response's history as cleared. |
|
|
72
|
+
| `encrypt_history!` | Mark the next response's history as encrypted. |
|
|
73
|
+
| `always`, `defer`, `optional`, `lazy`, `merge` | Prop wrappers for Inertia v2 transport modes. |
|
|
74
|
+
|
|
75
|
+
Compatibility aliases remain available: `inertia`, `render(inertia: ...)`,
|
|
76
|
+
`inertia_request?`, `inertia_errors`, `inertia_clear_history!`,
|
|
77
|
+
`inertia_encrypt_history!`, and `Inertia.defer` / `Inertia.merge` / etc.
|
|
70
78
|
|
|
71
79
|
### Class-level DSL
|
|
72
80
|
|
|
73
81
|
| DSL | purpose |
|
|
74
82
|
|---|---|
|
|
75
|
-
| `set :
|
|
76
|
-
| `set :
|
|
83
|
+
| `set :page_version, -> { ... }` | Asset version. Mismatch on Inertia GET → 409 + `X-Inertia-Location`. |
|
|
84
|
+
| `set :page_layout, :layout` | ERB layout used for full-page rendering (default `:layout`). |
|
|
77
85
|
| `set :inertia_encrypt_history, true` | Default `encryptHistory: true` on every page. |
|
|
78
|
-
| `
|
|
86
|
+
| `share_props do … end` | Block whose return Hash is merged into every page's props. |
|
|
87
|
+
|
|
88
|
+
Compatibility aliases remain available: `set :inertia_version`,
|
|
89
|
+
`set :inertia_layout`, and `inertia_share do ... end`.
|
|
79
90
|
|
|
80
91
|
### Prop wrappers (Inertia v2 transport modes)
|
|
81
92
|
|
|
82
93
|
```ruby
|
|
83
|
-
|
|
94
|
+
render 'Page',
|
|
84
95
|
todos: -> { Todo.all }, # plain lazy
|
|
85
|
-
csrf:
|
|
86
|
-
stats:
|
|
87
|
-
filter:
|
|
88
|
-
feed:
|
|
89
|
-
}
|
|
96
|
+
csrf: always { csrf_token }, # always sent
|
|
97
|
+
stats: defer(group: 'meta') { stats }, # excluded from initial response
|
|
98
|
+
filter: optional { params[:f] }, # only on partial-reload request
|
|
99
|
+
feed: merge(page_items) # client-side array merge
|
|
90
100
|
```
|
|
91
101
|
|
|
92
102
|
| wrapper | semantics |
|
|
93
103
|
|---|---|
|
|
94
104
|
| bare `Proc`/`->` | resolved every request when included; partial-reload aware. |
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
105
|
+
| `always { … }` | always included, even on partials that omit it. |
|
|
106
|
+
| `defer(group:) { … }` | excluded on initial visit; client refetches in second roundtrip. |
|
|
107
|
+
| `optional { … }` | only resolved when explicitly requested via `X-Inertia-Partial-Data`. |
|
|
108
|
+
| `lazy { … }` | alias of `optional` (Inertia v1 name). |
|
|
109
|
+
| `merge(value)` | sent as a merge prop (`mergeProps` array on page object). Honours `X-Inertia-Reset: prop1,prop2` (Inertia 2.0) — reset props are emitted as plain values and dropped from `mergeProps`. |
|
|
100
110
|
|
|
101
111
|
## Protocol features
|
|
102
112
|
|
|
@@ -110,9 +120,9 @@ inertia 'Page', props: {
|
|
|
110
120
|
303 so the browser follows with GET.
|
|
111
121
|
* **Partial reloads** — `X-Inertia-Partial-Component` + `X-Inertia-Partial-Data`
|
|
112
122
|
/ `X-Inertia-Partial-Except` headers narrow which props are resolved.
|
|
113
|
-
* **Encrypted history / clear history** — set per-app via setting or
|
|
114
|
-
|
|
115
|
-
* **Errors session** — `
|
|
123
|
+
* **Encrypted history / clear history** — set per-app via setting or per-route
|
|
124
|
+
via `encrypt_history!` / `clear_history!`.
|
|
125
|
+
* **Errors session** — `page_errors(field: 'msg')` survives one redirect
|
|
116
126
|
and is automatically swept on render.
|
|
117
127
|
|
|
118
128
|
## Validation pattern (no 422, no client state)
|
|
@@ -120,7 +130,7 @@ inertia 'Page', props: {
|
|
|
120
130
|
```ruby
|
|
121
131
|
post '/todos' do
|
|
122
132
|
if params[:title].to_s.strip.empty?
|
|
123
|
-
|
|
133
|
+
page_errors title: "can't be blank"
|
|
124
134
|
redirect back # 303 by middleware; Inertia client follows
|
|
125
135
|
else
|
|
126
136
|
Todo.create(params)
|
|
@@ -129,10 +139,9 @@ post '/todos' do
|
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
get '/' do
|
|
132
|
-
|
|
142
|
+
render 'Todos/Index',
|
|
133
143
|
todos: Todo.all,
|
|
134
144
|
values: { title: params[:title] }
|
|
135
|
-
}
|
|
136
145
|
end
|
|
137
146
|
```
|
|
138
147
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
if defined?(::HomuraRuntime) && defined?(::HomuraRuntime::AsyncRegistry)
|
|
14
14
|
::HomuraRuntime::AsyncRegistry.register_async_source do
|
|
15
|
-
async_method
|
|
16
|
-
async_method
|
|
15
|
+
async_method("Sinatra::Inertia::Response", :to_h)
|
|
16
|
+
async_method("Sinatra::Inertia::Response", :to_json)
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "rack/utils"
|
|
5
5
|
|
|
6
6
|
module Sinatra
|
|
7
7
|
module Inertia
|
|
@@ -36,9 +36,9 @@ module Sinatra
|
|
|
36
36
|
# this only when the consumer ships its own CSRF defence. Safer
|
|
37
37
|
# defaults assume the gem is responsible.
|
|
38
38
|
class CSRFMiddleware
|
|
39
|
-
COOKIE_NAME =
|
|
40
|
-
HEADER_KEY =
|
|
41
|
-
ENV_TOKEN_KEY =
|
|
39
|
+
COOKIE_NAME = "XSRF-TOKEN"
|
|
40
|
+
HEADER_KEY = "HTTP_X_XSRF_TOKEN"
|
|
41
|
+
ENV_TOKEN_KEY = "sinatra.inertia.csrf_token"
|
|
42
42
|
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
43
43
|
|
|
44
44
|
def initialize(app, same_site: :Lax)
|
|
@@ -54,31 +54,34 @@ module Sinatra
|
|
|
54
54
|
unless safe_method?(env)
|
|
55
55
|
header = env[HEADER_KEY].to_s
|
|
56
56
|
if existing.nil? || header.empty? || !secure_compare(header, existing)
|
|
57
|
-
return forbidden(
|
|
57
|
+
return (forbidden(
|
|
58
|
+
"CSRF token mismatch (expected matching X-XSRF-TOKEN header to XSRF-TOKEN cookie)"
|
|
59
|
+
))
|
|
58
60
|
end
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
status, headers, body = @app.call(env)
|
|
62
|
-
unless existing == token
|
|
63
|
-
set_cookie!(headers, token)
|
|
64
|
-
end
|
|
64
|
+
set_cookie!(headers, token) unless existing == token
|
|
65
65
|
[status, headers, body]
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
private
|
|
69
69
|
|
|
70
70
|
def safe_method?(env)
|
|
71
|
-
SAFE_METHODS.include?(env[
|
|
71
|
+
SAFE_METHODS.include?(env["REQUEST_METHOD"])
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def read_cookie(env)
|
|
75
|
-
cookie_header = env[
|
|
75
|
+
cookie_header = env["HTTP_COOKIE"].to_s
|
|
76
76
|
return nil if cookie_header.empty?
|
|
77
|
-
cookie_header
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
cookie_header
|
|
78
|
+
.split(/;\s*/)
|
|
79
|
+
.each do |pair|
|
|
80
|
+
name, value = pair.split("=", 2)
|
|
81
|
+
next unless name == COOKIE_NAME
|
|
82
|
+
return value
|
|
83
|
+
end
|
|
84
|
+
|
|
82
85
|
nil
|
|
83
86
|
end
|
|
84
87
|
|
|
@@ -99,26 +102,34 @@ module Sinatra
|
|
|
99
102
|
|
|
100
103
|
def set_cookie!(headers, token)
|
|
101
104
|
attrs = "#{COOKIE_NAME}=#{token}; Path=/; SameSite=#{@same_site}"
|
|
102
|
-
existing = headers[
|
|
105
|
+
existing = headers["Set-Cookie"]
|
|
103
106
|
# Normalise to a newline-joined String regardless of Rack 2/3
|
|
104
107
|
# conventions or downstream worker-runtime quirks. The Cloudflare
|
|
105
108
|
# Workers adapter that homura ships with serialises Array-shaped
|
|
106
109
|
# `Set-Cookie` headers as a literal JSON array, which breaks
|
|
107
110
|
# cookie parsing on the client.
|
|
108
111
|
prev = case existing
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
when nil, ""
|
|
113
|
+
nil
|
|
114
|
+
when Array
|
|
115
|
+
existing.join("\n")
|
|
116
|
+
else
|
|
117
|
+
existing.to_s
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
headers["Set-Cookie"] = prev ? "#{prev}\n#{attrs}" : attrs
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
def forbidden(message)
|
|
117
124
|
body = "#{message}\n"
|
|
118
|
-
[
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
[
|
|
126
|
+
403,
|
|
127
|
+
{
|
|
128
|
+
"Content-Type" => "text/plain; charset=utf-8",
|
|
129
|
+
"Content-Length" => body.bytesize.to_s
|
|
130
|
+
},
|
|
131
|
+
[body]
|
|
132
|
+
]
|
|
122
133
|
end
|
|
123
134
|
end
|
|
124
135
|
end
|
|
@@ -13,13 +13,12 @@ module Sinatra
|
|
|
13
13
|
# stats: Inertia.defer { compute_stats } # excluded from initial response, fetched in 2nd request
|
|
14
14
|
# csrf: Inertia.always { csrf_token } # included even on partial reloads that omit it
|
|
15
15
|
# filter: Inertia.optional { params[:f] } # only included when explicitly requested via partial
|
|
16
|
-
# feed:
|
|
17
|
-
# once: Inertia.once { current_time } # delivered exactly once; subsequent visits suppress
|
|
16
|
+
# feed: merge(page_items) # array merged with existing client-side feed
|
|
18
17
|
# }
|
|
19
18
|
class Prop
|
|
20
19
|
attr_reader :block, :value, :group
|
|
21
20
|
|
|
22
|
-
def initialize(block: nil, value: nil, group:
|
|
21
|
+
def initialize(block: nil, value: nil, group: "default")
|
|
23
22
|
@block = block
|
|
24
23
|
@value = value
|
|
25
24
|
@group = group
|
|
@@ -44,9 +43,6 @@ module Sinatra
|
|
|
44
43
|
# Should arrays returned by this prop be merged with the client's
|
|
45
44
|
# existing array (Inertia 2 merge semantics)?
|
|
46
45
|
def merge? = false
|
|
47
|
-
|
|
48
|
-
# Once-only delivery (cleared from session/state after first emission).
|
|
49
|
-
def once? = false
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
class AlwaysProp < Prop
|
|
@@ -74,7 +70,7 @@ module Sinatra
|
|
|
74
70
|
module_function
|
|
75
71
|
|
|
76
72
|
def always(value = nil, &block) = AlwaysProp.new(block: block, value: value)
|
|
77
|
-
def defer(group:
|
|
73
|
+
def defer(group: "default", &block) = DeferredProp.new(block: block, group: group)
|
|
78
74
|
def optional(&block) = OptionalProp.new(block: block)
|
|
79
75
|
def lazy(&block) = LazyProp.new(block: block)
|
|
80
76
|
def merge(value = nil, &block) = MergeProp.new(block: block, value: value)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "response"
|
|
5
6
|
|
|
6
7
|
module Sinatra
|
|
7
8
|
module Inertia
|
|
@@ -10,7 +11,7 @@ module Sinatra
|
|
|
10
11
|
module Helpers
|
|
11
12
|
# Render an Inertia response.
|
|
12
13
|
#
|
|
13
|
-
#
|
|
14
|
+
# render 'Todos/Index', todos: -> { Todo.all }
|
|
14
15
|
#
|
|
15
16
|
# Layout selection: the configured layout (default `:layout`) is
|
|
16
17
|
# rendered for full HTML responses. The view receives `@page_json`
|
|
@@ -18,17 +19,18 @@ module Sinatra
|
|
|
18
19
|
# attribute) and `@page` (the underlying Hash, useful for SSR or
|
|
19
20
|
# custom rendering).
|
|
20
21
|
def inertia(component, props: {}, layout: nil)
|
|
21
|
-
layout =
|
|
22
|
+
layout = current_page_layout if layout.nil?
|
|
22
23
|
|
|
23
24
|
version = current_inertia_version
|
|
24
25
|
shared = current_inertia_shared
|
|
25
26
|
encrypt = if !@inertia_encrypt_history_override.nil?
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
@inertia_encrypt_history_override == true
|
|
28
|
+
elsif settings.respond_to?(:inertia_encrypt_history)
|
|
29
|
+
settings.inertia_encrypt_history == true
|
|
30
|
+
else
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
32
34
|
clear = @inertia_clear_history == true
|
|
33
35
|
|
|
34
36
|
# Set the protocol response headers BEFORE we touch any async
|
|
@@ -40,8 +42,8 @@ module Sinatra
|
|
|
40
42
|
# regardless of how the underlying runtime schedules the
|
|
41
43
|
# awaited continuation.
|
|
42
44
|
if inertia_request?
|
|
43
|
-
content_type
|
|
44
|
-
headers
|
|
45
|
+
content_type("application/json; charset=utf-8")
|
|
46
|
+
headers("X-Inertia" => "true", "Vary" => "X-Inertia")
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
# Read errors *before* sweeping so the response carries them, then
|
|
@@ -69,25 +71,49 @@ module Sinatra
|
|
|
69
71
|
|
|
70
72
|
@page = page_hash
|
|
71
73
|
@page_json = ::Rack::Utils.escape_html(page_json)
|
|
72
|
-
erb
|
|
74
|
+
erb(layout, layout: false)
|
|
73
75
|
end
|
|
74
76
|
|
|
75
|
-
#
|
|
77
|
+
# Natural page-rendering API: `render 'Component', props_hash`.
|
|
78
|
+
# `render inertia: 'Component', props: {...}` remains available for
|
|
79
|
+
# existing apps, while non-page render calls still delegate to Sinatra.
|
|
76
80
|
# We must preserve Sinatra's `render(engine, data = nil, options = {}, locals = {}, &block)`
|
|
77
81
|
# signature for the non-inertia path, so we forward *args/**kwargs.
|
|
78
82
|
def render(*args, **kwargs, &block)
|
|
79
83
|
first = args.first
|
|
80
84
|
if args.length == 1 && first.is_a?(Hash) && first.key?(:inertia)
|
|
81
|
-
inertia(
|
|
85
|
+
inertia(
|
|
86
|
+
first[:inertia],
|
|
87
|
+
props: first[:props] || {},
|
|
88
|
+
layout: first[:layout]
|
|
89
|
+
)
|
|
82
90
|
elsif kwargs.key?(:inertia) && args.empty?
|
|
83
|
-
inertia(
|
|
91
|
+
inertia(
|
|
92
|
+
kwargs[:inertia],
|
|
93
|
+
props: kwargs[:props] || {},
|
|
94
|
+
layout: kwargs[:layout]
|
|
95
|
+
)
|
|
96
|
+
elsif first.is_a?(String) &&
|
|
97
|
+
args.length <= 2 &&
|
|
98
|
+
(args.length == 1 || args[1].is_a?(Hash))
|
|
99
|
+
layout = kwargs.delete(:layout)
|
|
100
|
+
props = {}
|
|
101
|
+
props.merge!(args[1]) if args[1].is_a?(Hash)
|
|
102
|
+
explicit_props = kwargs.delete(:props)
|
|
103
|
+
props.merge!(explicit_props) if explicit_props.is_a?(Hash)
|
|
104
|
+
props.merge!(kwargs)
|
|
105
|
+
inertia(first, props: props, layout: layout)
|
|
84
106
|
else
|
|
85
107
|
super(*args, **kwargs, &block)
|
|
86
108
|
end
|
|
87
109
|
end
|
|
88
110
|
|
|
89
111
|
def inertia_request?
|
|
90
|
-
request.env[
|
|
112
|
+
request.env["HTTP_X_INERTIA"] == "true"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def page_request?
|
|
116
|
+
inertia_request?
|
|
91
117
|
end
|
|
92
118
|
|
|
93
119
|
# CSRF token for the current request. Mounted by CSRFMiddleware
|
|
@@ -98,7 +124,27 @@ module Sinatra
|
|
|
98
124
|
# exchange is already handled by the Inertia client; this helper is
|
|
99
125
|
# mainly for hidden-field forms or non-XHR submissions.
|
|
100
126
|
def csrf_token
|
|
101
|
-
request.env[
|
|
127
|
+
request.env["sinatra.inertia.csrf_token"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def always(value = nil, &block)
|
|
131
|
+
Sinatra::Inertia.always(value, &block)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def defer(group: "default", &block)
|
|
135
|
+
Sinatra::Inertia.defer(group: group, &block)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def optional(&block)
|
|
139
|
+
Sinatra::Inertia.optional(&block)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def lazy(&block)
|
|
143
|
+
Sinatra::Inertia.lazy(&block)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def merge(value = nil, &block)
|
|
147
|
+
Sinatra::Inertia.merge(value, &block)
|
|
102
148
|
end
|
|
103
149
|
|
|
104
150
|
# ------------------------------------------------------------------
|
|
@@ -109,17 +155,21 @@ module Sinatra
|
|
|
109
155
|
merged = {}
|
|
110
156
|
blocks.each do |b|
|
|
111
157
|
v = instance_exec(&b)
|
|
112
|
-
if v.is_a?(Hash)
|
|
113
|
-
merged = deep_merge(merged, v)
|
|
114
|
-
end
|
|
158
|
+
merged = deep_merge(merged, v) if v.is_a?(Hash)
|
|
115
159
|
end
|
|
160
|
+
|
|
116
161
|
merged
|
|
117
162
|
end
|
|
118
163
|
|
|
119
164
|
# ------------------------------------------------------------------
|
|
120
165
|
# Asset version
|
|
121
166
|
def current_inertia_version
|
|
122
|
-
v = settings.respond_to?(:
|
|
167
|
+
v = if settings.respond_to?(:page_version)
|
|
168
|
+
settings.page_version
|
|
169
|
+
elsif settings.respond_to?(:inertia_version)
|
|
170
|
+
settings.inertia_version
|
|
171
|
+
end
|
|
172
|
+
|
|
123
173
|
v.respond_to?(:call) ? v.call.to_s : v.to_s
|
|
124
174
|
end
|
|
125
175
|
|
|
@@ -137,14 +187,26 @@ module Sinatra
|
|
|
137
187
|
end
|
|
138
188
|
end
|
|
139
189
|
|
|
190
|
+
def page_errors(payload = nil)
|
|
191
|
+
inertia_errors(payload)
|
|
192
|
+
end
|
|
193
|
+
|
|
140
194
|
def inertia_clear_history!
|
|
141
195
|
@inertia_clear_history = true
|
|
142
196
|
end
|
|
143
197
|
|
|
198
|
+
def clear_history!
|
|
199
|
+
inertia_clear_history!
|
|
200
|
+
end
|
|
201
|
+
|
|
144
202
|
def inertia_encrypt_history!(flag = true)
|
|
145
203
|
@inertia_encrypt_history_override = flag
|
|
146
204
|
end
|
|
147
205
|
|
|
206
|
+
def encrypt_history!(flag = true)
|
|
207
|
+
inertia_encrypt_history!(flag)
|
|
208
|
+
end
|
|
209
|
+
|
|
148
210
|
def inertia_errors_payload
|
|
149
211
|
errors = session[:_inertia_errors]
|
|
150
212
|
return nil if errors.nil?
|
|
@@ -160,13 +222,21 @@ module Sinatra
|
|
|
160
222
|
# assigning nil instead, which the JSON encoder still emits as
|
|
161
223
|
# `null` and makes `inertia_errors_payload` treat the field as
|
|
162
224
|
# absent on the next visit.
|
|
163
|
-
if session.respond_to?(:[]=)
|
|
164
|
-
session[:_inertia_errors] = nil
|
|
165
|
-
end
|
|
225
|
+
session[:_inertia_errors] = nil if session.respond_to?(:[]=)
|
|
166
226
|
end
|
|
167
227
|
|
|
168
228
|
private
|
|
169
229
|
|
|
230
|
+
def current_page_layout
|
|
231
|
+
if settings.respond_to?(:page_layout)
|
|
232
|
+
settings.page_layout
|
|
233
|
+
elsif settings.respond_to?(:inertia_layout)
|
|
234
|
+
settings.inertia_layout
|
|
235
|
+
else
|
|
236
|
+
:layout
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
170
240
|
def deep_merge(a, b)
|
|
171
241
|
a.merge(b) do |_k, av, bv|
|
|
172
242
|
(av.is_a?(Hash) && bv.is_a?(Hash)) ? deep_merge(av, bv) : bv
|
|
@@ -19,8 +19,8 @@ module Sinatra
|
|
|
19
19
|
# The middleware is `register`-ed automatically by `Sinatra::Inertia`
|
|
20
20
|
# via `app.use`, so consumer apps don't need to wire it manually.
|
|
21
21
|
class Middleware
|
|
22
|
-
INERTIA_HEADER =
|
|
23
|
-
INERTIA_VERSION_HEADER =
|
|
22
|
+
INERTIA_HEADER = "HTTP_X_INERTIA"
|
|
23
|
+
INERTIA_VERSION_HEADER = "HTTP_X_INERTIA_VERSION"
|
|
24
24
|
|
|
25
25
|
def initialize(app, version:)
|
|
26
26
|
@app = app
|
|
@@ -29,10 +29,10 @@ module Sinatra
|
|
|
29
29
|
|
|
30
30
|
def call(env)
|
|
31
31
|
if inertia_get?(env) && version_mismatch?(env)
|
|
32
|
-
location = env[
|
|
32
|
+
location = env["REQUEST_URI"] || build_url(env)
|
|
33
33
|
return [
|
|
34
34
|
409,
|
|
35
|
-
{
|
|
35
|
+
{"X-Inertia-Location" => location, "Vary" => "X-Inertia"},
|
|
36
36
|
[]
|
|
37
37
|
]
|
|
38
38
|
end
|
|
@@ -44,8 +44,9 @@ module Sinatra
|
|
|
44
44
|
# requests: a Sinatra app may serve plain REST endpoints alongside
|
|
45
45
|
# Inertia pages, and rewriting their 302s would silently change
|
|
46
46
|
# HTTP semantics for non-Inertia clients.
|
|
47
|
-
if status == 302 &&
|
|
48
|
-
|
|
47
|
+
if status == 302 &&
|
|
48
|
+
env[INERTIA_HEADER] == "true" &&
|
|
49
|
+
%w[POST PUT PATCH DELETE].include?(env["REQUEST_METHOD"])
|
|
49
50
|
status = 303
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -55,7 +56,7 @@ module Sinatra
|
|
|
55
56
|
private
|
|
56
57
|
|
|
57
58
|
def inertia_get?(env)
|
|
58
|
-
env[INERTIA_HEADER] ==
|
|
59
|
+
env[INERTIA_HEADER] == "true" && env["REQUEST_METHOD"] == "GET"
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def version_mismatch?(env)
|
|
@@ -70,10 +71,10 @@ module Sinatra
|
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
def build_url(env)
|
|
73
|
-
scheme = env[
|
|
74
|
-
host = env[
|
|
75
|
-
path = env[
|
|
76
|
-
qs = env[
|
|
74
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
75
|
+
host = env["HTTP_HOST"] || env["SERVER_NAME"]
|
|
76
|
+
path = env["PATH_INFO"]
|
|
77
|
+
qs = env["QUERY_STRING"]
|
|
77
78
|
full = +"#{scheme}://#{host}#{path}"
|
|
78
79
|
full << "?#{qs}" if qs && !qs.empty?
|
|
79
80
|
full
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
3
|
+
require_relative "deferred"
|
|
4
4
|
|
|
5
5
|
module Sinatra
|
|
6
6
|
module Inertia
|
|
@@ -12,12 +12,29 @@ module Sinatra
|
|
|
12
12
|
# responses, or interpolated into the layout's `data-page` attribute
|
|
13
13
|
# for full HTML responses.
|
|
14
14
|
class Response
|
|
15
|
-
attr_reader
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
attr_reader(
|
|
16
|
+
:component,
|
|
17
|
+
:props,
|
|
18
|
+
:request,
|
|
19
|
+
:version,
|
|
20
|
+
:url,
|
|
21
|
+
:encrypt_history,
|
|
22
|
+
:clear_history,
|
|
23
|
+
:shared,
|
|
24
|
+
:errors
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
component:,
|
|
29
|
+
props:,
|
|
30
|
+
request:,
|
|
31
|
+
version:,
|
|
32
|
+
url: nil,
|
|
33
|
+
encrypt_history: false,
|
|
34
|
+
clear_history: false,
|
|
35
|
+
shared: {},
|
|
36
|
+
errors: nil
|
|
37
|
+
)
|
|
21
38
|
@component = component
|
|
22
39
|
@props = props || {}
|
|
23
40
|
@request = request
|
|
@@ -64,6 +81,7 @@ module Sinatra
|
|
|
64
81
|
elsif value.is_a?(Prop) && value.deferred?
|
|
65
82
|
(deferred_groups[value.group] ||= []) << k.to_s
|
|
66
83
|
end
|
|
84
|
+
|
|
67
85
|
i += 1
|
|
68
86
|
end
|
|
69
87
|
|
|
@@ -85,19 +103,19 @@ module Sinatra
|
|
|
85
103
|
private
|
|
86
104
|
|
|
87
105
|
def partial_request?
|
|
88
|
-
request.env[
|
|
106
|
+
request.env["HTTP_X_INERTIA_PARTIAL_COMPONENT"] == component
|
|
89
107
|
end
|
|
90
108
|
|
|
91
109
|
def partial_data_keys
|
|
92
|
-
raw = request.env[
|
|
110
|
+
raw = request.env["HTTP_X_INERTIA_PARTIAL_DATA"].to_s
|
|
93
111
|
return nil if raw.empty?
|
|
94
|
-
raw.split(
|
|
112
|
+
raw.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
95
113
|
end
|
|
96
114
|
|
|
97
115
|
def partial_except_keys
|
|
98
|
-
raw = request.env[
|
|
116
|
+
raw = request.env["HTTP_X_INERTIA_PARTIAL_EXCEPT"].to_s
|
|
99
117
|
return nil if raw.empty?
|
|
100
|
-
raw.split(
|
|
118
|
+
raw.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
101
119
|
end
|
|
102
120
|
|
|
103
121
|
# Inertia 2.0: `X-Inertia-Reset: a,b` tells the server "the client
|
|
@@ -106,29 +124,30 @@ module Sinatra
|
|
|
106
124
|
# outbound `mergeProps` array — the value itself is still resolved
|
|
107
125
|
# and emitted, so the client just doesn't accumulate it.
|
|
108
126
|
def reset_keys
|
|
109
|
-
raw = request.env[
|
|
127
|
+
raw = request.env["HTTP_X_INERTIA_RESET"].to_s
|
|
110
128
|
return [] if raw.empty?
|
|
111
|
-
raw.split(
|
|
129
|
+
raw.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
112
130
|
end
|
|
113
131
|
|
|
114
132
|
# Returns [included?, resolved_value]
|
|
115
133
|
def decide(value, key, partial, partial_data, partial_except)
|
|
116
134
|
if value.is_a?(Prop)
|
|
117
135
|
if value.always?
|
|
118
|
-
return
|
|
136
|
+
return true, value.resolve
|
|
119
137
|
elsif value.deferred?
|
|
120
|
-
return
|
|
121
|
-
return
|
|
138
|
+
return false, nil unless partial && partial_data&.include?(key)
|
|
139
|
+
return true, value.resolve
|
|
122
140
|
elsif value.optional?
|
|
123
|
-
return
|
|
124
|
-
return
|
|
141
|
+
return false, nil unless partial && partial_data&.include?(key)
|
|
142
|
+
return true, value.resolve
|
|
125
143
|
else
|
|
126
144
|
# merge / once / plain Prop
|
|
127
145
|
if partial
|
|
128
|
-
return
|
|
129
|
-
return
|
|
146
|
+
return false, nil if partial_data && !partial_data.include?(key)
|
|
147
|
+
return false, nil if partial_except&.include?(key)
|
|
130
148
|
end
|
|
131
|
-
|
|
149
|
+
|
|
150
|
+
return true, value.resolve
|
|
132
151
|
end
|
|
133
152
|
end
|
|
134
153
|
|
|
@@ -136,17 +155,19 @@ module Sinatra
|
|
|
136
155
|
# A bare Proc/Lambda is treated as plain lazy: resolved every
|
|
137
156
|
# request, but only when included.
|
|
138
157
|
if partial
|
|
139
|
-
return
|
|
140
|
-
return
|
|
158
|
+
return false, nil if partial_data && !partial_data.include?(key)
|
|
159
|
+
return false, nil if partial_except&.include?(key)
|
|
141
160
|
end
|
|
142
|
-
|
|
161
|
+
|
|
162
|
+
return true, value.call
|
|
143
163
|
end
|
|
144
164
|
|
|
145
165
|
# Plain value
|
|
146
166
|
if partial
|
|
147
|
-
return
|
|
148
|
-
return
|
|
167
|
+
return false, nil if partial_data && !partial_data.include?(key)
|
|
168
|
+
return false, nil if partial_except&.include?(key)
|
|
149
169
|
end
|
|
170
|
+
|
|
150
171
|
[true, value]
|
|
151
172
|
end
|
|
152
173
|
|
|
@@ -167,7 +188,9 @@ module Sinatra
|
|
|
167
188
|
# via `.__await__`. On MRI this branch is dead code (no Cloudflare
|
|
168
189
|
# constant, no js_promise?), so plain Ruby tests are unaffected.
|
|
169
190
|
def await_if_promise(value)
|
|
170
|
-
if defined?(::Cloudflare) &&
|
|
191
|
+
if defined?(::Cloudflare) &&
|
|
192
|
+
::Cloudflare.respond_to?(:js_promise?) &&
|
|
193
|
+
::Cloudflare.js_promise?(value)
|
|
171
194
|
value.__await__
|
|
172
195
|
else
|
|
173
196
|
value
|
data/lib/sinatra/inertia.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
|
|
5
|
+
require_relative "inertia/version"
|
|
6
|
+
require_relative "inertia/errors"
|
|
7
|
+
require_relative "inertia/deferred"
|
|
8
|
+
require_relative "inertia/response"
|
|
9
|
+
require_relative "inertia/middleware"
|
|
10
|
+
require_relative "inertia/csrf_middleware"
|
|
11
|
+
require_relative "inertia/helpers"
|
|
12
|
+
require_relative "inertia/async_sources"
|
|
12
13
|
|
|
13
14
|
module Sinatra
|
|
14
15
|
# Inertia.js v2 protocol adapter for Sinatra.
|
|
@@ -16,47 +17,69 @@ module Sinatra
|
|
|
16
17
|
# class App < Sinatra::Base
|
|
17
18
|
# register Sinatra::Inertia
|
|
18
19
|
#
|
|
19
|
-
# set :
|
|
20
|
-
# set :
|
|
20
|
+
# set :page_version, -> { ASSETS_VERSION }
|
|
21
|
+
# set :page_layout, :layout
|
|
21
22
|
#
|
|
22
|
-
#
|
|
23
|
+
# share_props do
|
|
23
24
|
# { auth: { user: current_user }, flash: flash_payload }
|
|
24
25
|
# end
|
|
25
26
|
#
|
|
26
27
|
# get '/' do
|
|
27
|
-
#
|
|
28
|
+
# render 'Todos/Index', todos: -> { Todo.all }
|
|
28
29
|
# end
|
|
29
30
|
# end
|
|
30
31
|
#
|
|
31
32
|
# See README.md for the full feature matrix.
|
|
32
33
|
module Inertia
|
|
33
34
|
def self.registered(app)
|
|
34
|
-
# Default settings
|
|
35
|
-
|
|
36
|
-
app.set
|
|
37
|
-
app.set
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
# Default settings. `page_*` is the recommended application-facing API;
|
|
36
|
+
# `inertia_*` remains supported for existing apps and lower-level tuning.
|
|
37
|
+
app.set(:inertia_version, "1") unless app.respond_to?(:inertia_version)
|
|
38
|
+
app.set(:inertia_layout, :layout) unless app.respond_to?(:inertia_layout)
|
|
39
|
+
unless app.respond_to?(:inertia_encrypt_history)
|
|
40
|
+
app.set(:inertia_encrypt_history, false)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless app.respond_to?(:inertia_csrf_protection)
|
|
44
|
+
app.set(:inertia_csrf_protection, true)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
app.set(:inertia_share_blocks, [])
|
|
40
48
|
|
|
41
49
|
# Mount CSRF middleware (double-submit XSRF-TOKEN cookie that
|
|
42
50
|
# @inertiajs/* clients honour). Opt-out via
|
|
43
51
|
# `set :inertia_csrf_protection, false` when the consumer ships
|
|
44
52
|
# its own (e.g. Rack::Protection::AuthenticityToken).
|
|
45
53
|
if app.settings.inertia_csrf_protection
|
|
46
|
-
app.use
|
|
54
|
+
app.use(Sinatra::Inertia::CSRFMiddleware)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
# Mount protocol middleware (version mismatch + 303 redirect promotion).
|
|
50
|
-
app.use
|
|
58
|
+
app.use(
|
|
59
|
+
Sinatra::Inertia::Middleware,
|
|
60
|
+
version: lambda {
|
|
61
|
+
version = if app.settings.respond_to?(:page_version)
|
|
62
|
+
app.settings.page_version
|
|
63
|
+
else
|
|
64
|
+
app.settings.inertia_version
|
|
65
|
+
end
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
version.respond_to?(:call) ? version.call.to_s : version.to_s
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Class-level DSL: `share_props { ... }` registers a block whose return
|
|
72
|
+
# value is merged into every page's props.
|
|
54
73
|
app.define_singleton_method(:inertia_share) do |&block|
|
|
55
|
-
raise ArgumentError,
|
|
74
|
+
raise ArgumentError, "inertia_share requires a block" unless block
|
|
56
75
|
settings.inertia_share_blocks = settings.inertia_share_blocks + [block]
|
|
57
76
|
end
|
|
58
77
|
|
|
59
|
-
app.
|
|
78
|
+
app.define_singleton_method(:share_props) do |&block|
|
|
79
|
+
inertia_share(&block)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
app.helpers(Sinatra::Inertia::Helpers)
|
|
60
83
|
end
|
|
61
84
|
|
|
62
85
|
# Convenience module-level constructors so code can write
|
|
@@ -72,4 +95,3 @@ end
|
|
|
72
95
|
# overwrite an existing `::Inertia` constant — if your app has one, use
|
|
73
96
|
# `Sinatra::Inertia.defer` instead.
|
|
74
97
|
::Inertia = Sinatra::Inertia unless defined?(::Inertia)
|
|
75
|
-
|