sinatra-inertia 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +155 -0
- data/lib/sinatra/inertia/async_sources.rb +18 -0
- data/lib/sinatra/inertia/csrf_middleware.rb +125 -0
- data/lib/sinatra/inertia/deferred.rb +82 -0
- data/lib/sinatra/inertia/errors.rb +8 -0
- data/lib/sinatra/inertia/helpers.rb +169 -0
- data/lib/sinatra/inertia/middleware.rb +83 -0
- data/lib/sinatra/inertia/response.rb +178 -0
- data/lib/sinatra/inertia/version.rb +7 -0
- data/lib/sinatra/inertia.rb +75 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 13a2feafd6c4d938912eed9edfe3b73df0b9fc5cabc0039de38a50c7ed7825cf
|
|
4
|
+
data.tar.gz: c15cf28f5b42b1babc98c0050a58abc81d26e1bc8b75fe1ca9cd7acf91573bb4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 22916e50a36a6f900a2cc8d100952596fa46c0ae1c191a1a129c286237bdae9b471ab2749955826300089eb3920b554aa2747cd5808c8cbc24a50923eabdfd83
|
|
7
|
+
data.tar.gz: a18a57fd4e7e2e8a1ccae01ad80fd3b73c7a1779d236b22b7e4265bf4240d16daa93ee7ff37a7590eca304b9fc81b5ed6675a3700f6efce24916fc2d7317c86e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-04-29
|
|
4
|
+
|
|
5
|
+
* Initial release.
|
|
6
|
+
* Full Inertia.js v2 wire protocol: page-object responses, version
|
|
7
|
+
mismatch (409 + X-Inertia-Location), partial reloads
|
|
8
|
+
(X-Inertia-Partial-{Component,Data,Except}), 303 redirect promotion.
|
|
9
|
+
* Prop wrappers: `Inertia.always`, `Inertia.defer(group:)`,
|
|
10
|
+
`Inertia.optional` (alias `Inertia.lazy`), `Inertia.merge`,
|
|
11
|
+
`Inertia.once`.
|
|
12
|
+
* Class-level DSL: `set :inertia_version`, `set :inertia_layout`,
|
|
13
|
+
`set :inertia_encrypt_history`, `inertia_share do … end`.
|
|
14
|
+
* Per-request helpers: `inertia(component, props:)`, `render(inertia:)`
|
|
15
|
+
alias, `inertia_request?`, `inertia_errors`, `inertia_clear_history!`,
|
|
16
|
+
`inertia_encrypt_history!`.
|
|
17
|
+
* Auto session sweep of validation errors on render.
|
|
18
|
+
* Pure Sinatra dependency (no Rails, no homura coupling).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kazuhiro Homma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# sinatra-inertia
|
|
2
|
+
|
|
3
|
+
A Sinatra extension that implements the full **Inertia.js v2** wire protocol —
|
|
4
|
+
page-object responses, version-mismatch detection, partial reloads,
|
|
5
|
+
deferred / lazy / always / optional / merge / once props, encrypted history,
|
|
6
|
+
303 redirect promotion, and error/flash session sweeps.
|
|
7
|
+
|
|
8
|
+
Pure Sinatra-compatible: depends only on `sinatra` and `rack`. Runs on
|
|
9
|
+
MRI Ruby and on the [homura](https://github.com/kazuph/homura) Cloudflare
|
|
10
|
+
Workers + Opal stack.
|
|
11
|
+
|
|
12
|
+
## Why "驚き最小"?
|
|
13
|
+
|
|
14
|
+
Two principles drove the API:
|
|
15
|
+
|
|
16
|
+
1. **Sinatra慣習に乗る.** Rendering a page is `inertia 'Component', props: {...}` —
|
|
17
|
+
the same shape as Sinatra's `erb :index`. A `render inertia: 'Component'`
|
|
18
|
+
alias is also provided for Rails refugees.
|
|
19
|
+
2. **Class-level config uses `set` and `inertia_share do ... end`,**
|
|
20
|
+
matching Sinatra's existing DSL surface (`set :views`, `helpers do … end`).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Gemfile
|
|
26
|
+
gem 'sinatra'
|
|
27
|
+
gem 'sinatra-inertia'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Hello, Inertia
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require 'sinatra'
|
|
34
|
+
require 'sinatra/inertia'
|
|
35
|
+
|
|
36
|
+
set :inertia_version, -> { ENV.fetch('ASSETS_VERSION', '1') }
|
|
37
|
+
|
|
38
|
+
get '/' do
|
|
39
|
+
inertia 'Pages/Hello', props: { name: 'world' }
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```erb
|
|
44
|
+
<!-- views/layout.erb -->
|
|
45
|
+
<!doctype html>
|
|
46
|
+
<html>
|
|
47
|
+
<head>
|
|
48
|
+
<title>App</title>
|
|
49
|
+
<link rel="stylesheet" href="/assets/app.css">
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<div id="app" data-page="<%= @page_json %>"></div>
|
|
53
|
+
<script type="module" src="/assets/app.js"></script>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Public API
|
|
59
|
+
|
|
60
|
+
### Helpers (per-request)
|
|
61
|
+
|
|
62
|
+
| helper | purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `inertia(component, props:, layout:)` | Render an Inertia response (HTML on first hit, JSON on Inertia visit). |
|
|
65
|
+
| `render(inertia: 'Comp', props: {...})` | Rails-style alias for the above. |
|
|
66
|
+
| `inertia_request?` | True when `X-Inertia: true` header is present. |
|
|
67
|
+
| `inertia_errors(payload = nil)` | Read or write validation errors that survive one redirect. |
|
|
68
|
+
| `inertia_clear_history!` | Mark the next response's history as cleared. |
|
|
69
|
+
| `inertia_encrypt_history!` | Mark the next response's history as encrypted. |
|
|
70
|
+
|
|
71
|
+
### Class-level DSL
|
|
72
|
+
|
|
73
|
+
| DSL | purpose |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `set :inertia_version, -> { ... }` | Asset version. Mismatch on Inertia GET → 409 + `X-Inertia-Location`. |
|
|
76
|
+
| `set :inertia_layout, :layout` | ERB layout used for full-page rendering (default `:layout`). |
|
|
77
|
+
| `set :inertia_encrypt_history, true` | Default `encryptHistory: true` on every page. |
|
|
78
|
+
| `inertia_share do … end` | Block whose return Hash is merged into every page's props. |
|
|
79
|
+
|
|
80
|
+
### Prop wrappers (Inertia v2 transport modes)
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
inertia 'Page', props: {
|
|
84
|
+
todos: -> { Todo.all }, # plain lazy
|
|
85
|
+
csrf: Inertia.always { csrf_token }, # always sent
|
|
86
|
+
stats: Inertia.defer(group: 'meta') { stats }, # excluded from initial response
|
|
87
|
+
filter: Inertia.optional { params[:f] }, # only on partial-reload request
|
|
88
|
+
feed: Inertia.merge(page_items) # client-side array merge
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| wrapper | semantics |
|
|
93
|
+
|---|---|
|
|
94
|
+
| bare `Proc`/`->` | resolved every request when included; partial-reload aware. |
|
|
95
|
+
| `Inertia.always { … }` | always included, even on partials that omit it. |
|
|
96
|
+
| `Inertia.defer(group:) { … }` | excluded on initial visit; client refetches in second roundtrip. |
|
|
97
|
+
| `Inertia.optional { … }` | only resolved when explicitly requested via `X-Inertia-Partial-Data`. |
|
|
98
|
+
| `Inertia.lazy { … }` | alias of `optional` (Inertia v1 name). |
|
|
99
|
+
| `Inertia.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
|
+
|
|
101
|
+
## Protocol features
|
|
102
|
+
|
|
103
|
+
* **Initial GET** — full HTML response. The layout sees `@page_json` (HTML-
|
|
104
|
+
escaped JSON) and `@page` (raw Hash).
|
|
105
|
+
* **Inertia visit** — `X-Inertia: true` request gets `Content-Type:
|
|
106
|
+
application/json`, `X-Inertia: true`, `Vary: X-Inertia`, body = page object.
|
|
107
|
+
* **Version mismatch** — Inertia GET with mismatched `X-Inertia-Version` →
|
|
108
|
+
`409 Conflict` + `X-Inertia-Location: <url>`. Client hard-reloads.
|
|
109
|
+
* **303 redirect promotion** — non-GET 302 responses are auto-promoted to
|
|
110
|
+
303 so the browser follows with GET.
|
|
111
|
+
* **Partial reloads** — `X-Inertia-Partial-Component` + `X-Inertia-Partial-Data`
|
|
112
|
+
/ `X-Inertia-Partial-Except` headers narrow which props are resolved.
|
|
113
|
+
* **Encrypted history / clear history** — set per-app via setting or
|
|
114
|
+
per-route via `inertia_encrypt_history!` / `inertia_clear_history!`.
|
|
115
|
+
* **Errors session** — `inertia_errors(field: 'msg')` survives one redirect
|
|
116
|
+
and is automatically swept on render.
|
|
117
|
+
|
|
118
|
+
## Validation pattern (no 422, no client state)
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
post '/todos' do
|
|
122
|
+
if params[:title].to_s.strip.empty?
|
|
123
|
+
inertia_errors title: "can't be blank"
|
|
124
|
+
redirect back # 303 by middleware; Inertia client follows
|
|
125
|
+
else
|
|
126
|
+
Todo.create(params)
|
|
127
|
+
redirect '/', 303
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
get '/' do
|
|
132
|
+
inertia 'Todos/Index', props: {
|
|
133
|
+
todos: Todo.all,
|
|
134
|
+
values: { title: params[:title] }
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The error payload appears on the next render's `props.errors` and is
|
|
140
|
+
swept from the session — exactly the experience described in the
|
|
141
|
+
"modern monolith" articles, no 422 dance, no client state machine.
|
|
142
|
+
|
|
143
|
+
## Compatibility
|
|
144
|
+
|
|
145
|
+
* MRI Ruby ≥ 3.1
|
|
146
|
+
* Sinatra ≥ 3.0 (incl. 4.x)
|
|
147
|
+
* Rack ≥ 2.0 (incl. 3.x)
|
|
148
|
+
* homura (Opal on Cloudflare Workers) — vendored Sinatra/Rack work as-is,
|
|
149
|
+
but **set `:logging, false`** in Workers builds because
|
|
150
|
+
`Rack::CommonLogger` uses `String#gsub!` (Opal does not implement
|
|
151
|
+
mutable string methods).
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Registers `Sinatra::Inertia::Response`'s public methods as async sources
|
|
4
|
+
# for the homura `auto-await` analyzer. When this gem is consumed inside a
|
|
5
|
+
# homura Cloudflare Workers app, the analyzer sees `response.to_h` /
|
|
6
|
+
# `response.to_json` calls and inserts `__await__` automatically — that's
|
|
7
|
+
# how lazy/defer Procs returning JS Promises actually resolve before
|
|
8
|
+
# JSON-serialization.
|
|
9
|
+
#
|
|
10
|
+
# Loaded only when the homura runtime is present (MRI / pure-Sinatra
|
|
11
|
+
# environments don't need this; their `to_h` is fully synchronous).
|
|
12
|
+
|
|
13
|
+
if defined?(::CloudflareWorkers) && defined?(::CloudflareWorkers::AsyncRegistry)
|
|
14
|
+
::CloudflareWorkers::AsyncRegistry.register_async_source do
|
|
15
|
+
async_method 'Sinatra::Inertia::Response', :to_h
|
|
16
|
+
async_method 'Sinatra::Inertia::Response', :to_json
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'rack/utils'
|
|
5
|
+
|
|
6
|
+
module Sinatra
|
|
7
|
+
module Inertia
|
|
8
|
+
# Rack middleware implementing the Inertia / Laravel "double-submit
|
|
9
|
+
# cookie" CSRF pattern that `@inertiajs/react` and `@inertiajs/vue3`
|
|
10
|
+
# honour out of the box.
|
|
11
|
+
#
|
|
12
|
+
# Behaviour
|
|
13
|
+
# ---------
|
|
14
|
+
# * On every request, ensure a token cookie named `XSRF-TOKEN` is
|
|
15
|
+
# present (generate + set on the response if missing).
|
|
16
|
+
# * For non-safe methods (POST / PUT / PATCH / DELETE), require the
|
|
17
|
+
# request to send `X-XSRF-TOKEN` whose value matches the cookie.
|
|
18
|
+
# Mismatch → `403 Forbidden`.
|
|
19
|
+
# * The cookie is *not* HttpOnly — Inertia's client reads it from
|
|
20
|
+
# `document.cookie` and forwards it as `X-XSRF-TOKEN` automatically.
|
|
21
|
+
#
|
|
22
|
+
# Caveats
|
|
23
|
+
# -------
|
|
24
|
+
# Double-submit cookie is the standard Inertia/Laravel pattern but is
|
|
25
|
+
# weaker than synchronizer-token CSRF when an attacker has any
|
|
26
|
+
# script-injection foothold. Pair with:
|
|
27
|
+
# * `SameSite=Lax` (default below) — the cookie won't ride
|
|
28
|
+
# cross-site form posts.
|
|
29
|
+
# * Strict CSP / no XSS.
|
|
30
|
+
# * Optionally, server-side session-bound tokens via
|
|
31
|
+
# `Rack::Protection::AuthenticityToken` instead.
|
|
32
|
+
#
|
|
33
|
+
# Configuration
|
|
34
|
+
# -------------
|
|
35
|
+
# `set :inertia_csrf_protection, false` disables this middleware. Use
|
|
36
|
+
# this only when the consumer ships its own CSRF defence. Safer
|
|
37
|
+
# defaults assume the gem is responsible.
|
|
38
|
+
class CSRFMiddleware
|
|
39
|
+
COOKIE_NAME = 'XSRF-TOKEN'
|
|
40
|
+
HEADER_KEY = 'HTTP_X_XSRF_TOKEN'
|
|
41
|
+
ENV_TOKEN_KEY = 'sinatra.inertia.csrf_token'
|
|
42
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
43
|
+
|
|
44
|
+
def initialize(app, same_site: :Lax)
|
|
45
|
+
@app = app
|
|
46
|
+
@same_site = same_site
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(env)
|
|
50
|
+
existing = read_cookie(env)
|
|
51
|
+
token = existing || SecureRandom.urlsafe_base64(32)
|
|
52
|
+
env[ENV_TOKEN_KEY] = token
|
|
53
|
+
|
|
54
|
+
unless safe_method?(env)
|
|
55
|
+
header = env[HEADER_KEY].to_s
|
|
56
|
+
if existing.nil? || header.empty? || !secure_compare(header, existing)
|
|
57
|
+
return forbidden('CSRF token mismatch (expected matching X-XSRF-TOKEN header to XSRF-TOKEN cookie)')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
status, headers, body = @app.call(env)
|
|
62
|
+
unless existing == token
|
|
63
|
+
set_cookie!(headers, token)
|
|
64
|
+
end
|
|
65
|
+
[status, headers, body]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def safe_method?(env)
|
|
71
|
+
SAFE_METHODS.include?(env['REQUEST_METHOD'])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_cookie(env)
|
|
75
|
+
cookie_header = env['HTTP_COOKIE'].to_s
|
|
76
|
+
return nil if cookie_header.empty?
|
|
77
|
+
cookie_header.split(/;\s*/).each do |pair|
|
|
78
|
+
name, value = pair.split('=', 2)
|
|
79
|
+
next unless name == COOKIE_NAME
|
|
80
|
+
return value
|
|
81
|
+
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def secure_compare(a, b)
|
|
86
|
+
# Constant-time compare. Avoid Rack::Utils.secure_compare here:
|
|
87
|
+
# the upstream implementation calls
|
|
88
|
+
# `OpenSSL.fixed_length_secure_compare` first, and on the homura
|
|
89
|
+
# Opal-on-Workers build that path either raises or silently
|
|
90
|
+
# diverges. Pure-Ruby compare keeps behaviour identical between
|
|
91
|
+
# MRI and Opal.
|
|
92
|
+
a = a.to_s
|
|
93
|
+
b = b.to_s
|
|
94
|
+
return false if a.bytesize != b.bytesize
|
|
95
|
+
diff = 0
|
|
96
|
+
a.bytes.zip(b.bytes) { |ai, bi| diff |= ai ^ bi }
|
|
97
|
+
diff.zero?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def set_cookie!(headers, token)
|
|
101
|
+
attrs = "#{COOKIE_NAME}=#{token}; Path=/; SameSite=#{@same_site}"
|
|
102
|
+
existing = headers['Set-Cookie']
|
|
103
|
+
# Normalise to a newline-joined String regardless of Rack 2/3
|
|
104
|
+
# conventions or downstream worker-runtime quirks. The Cloudflare
|
|
105
|
+
# Workers adapter that homura ships with serialises Array-shaped
|
|
106
|
+
# `Set-Cookie` headers as a literal JSON array, which breaks
|
|
107
|
+
# cookie parsing on the client.
|
|
108
|
+
prev = case existing
|
|
109
|
+
when nil, '' then nil
|
|
110
|
+
when Array then existing.join("\n")
|
|
111
|
+
else existing.to_s
|
|
112
|
+
end
|
|
113
|
+
headers['Set-Cookie'] = prev ? "#{prev}\n#{attrs}" : attrs
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def forbidden(message)
|
|
117
|
+
body = "#{message}\n"
|
|
118
|
+
[403, {
|
|
119
|
+
'Content-Type' => 'text/plain; charset=utf-8',
|
|
120
|
+
'Content-Length' => body.bytesize.to_s
|
|
121
|
+
}, [body]]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sinatra
|
|
4
|
+
module Inertia
|
|
5
|
+
# Wrappers that mark props with Inertia v2 transport modes. They are
|
|
6
|
+
# plain value objects with a Proc payload; resolution happens inside
|
|
7
|
+
# PropsResolver (which knows whether the current request is a partial
|
|
8
|
+
# reload, what component it targets, what fields are requested, etc.).
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# inertia 'Page', props: {
|
|
12
|
+
# todos: -> { Todo.all }, # plain lazy: included by default, resolved on demand
|
|
13
|
+
# stats: Inertia.defer { compute_stats } # excluded from initial response, fetched in 2nd request
|
|
14
|
+
# csrf: Inertia.always { csrf_token } # included even on partial reloads that omit it
|
|
15
|
+
# filter: Inertia.optional { params[:f] } # only included when explicitly requested via partial
|
|
16
|
+
# feed: Inertia.merge(page_items) # array merged with existing client-side feed
|
|
17
|
+
# once: Inertia.once { current_time } # delivered exactly once; subsequent visits suppress
|
|
18
|
+
# }
|
|
19
|
+
class Prop
|
|
20
|
+
attr_reader :block, :value, :group
|
|
21
|
+
|
|
22
|
+
def initialize(block: nil, value: nil, group: 'default')
|
|
23
|
+
@block = block
|
|
24
|
+
@value = value
|
|
25
|
+
@group = group
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve
|
|
29
|
+
block ? block.call : value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Should this prop be included in *every* response (including partials
|
|
33
|
+
# that did not request it)?
|
|
34
|
+
def always? = false
|
|
35
|
+
|
|
36
|
+
# Is the value sent in the initial response, or deferred to a second
|
|
37
|
+
# roundtrip?
|
|
38
|
+
def deferred? = false
|
|
39
|
+
|
|
40
|
+
# Is this prop only included when explicitly requested via
|
|
41
|
+
# X-Inertia-Partial-Data?
|
|
42
|
+
def optional? = false
|
|
43
|
+
|
|
44
|
+
# Should arrays returned by this prop be merged with the client's
|
|
45
|
+
# existing array (Inertia 2 merge semantics)?
|
|
46
|
+
def merge? = false
|
|
47
|
+
|
|
48
|
+
# Once-only delivery (cleared from session/state after first emission).
|
|
49
|
+
def once? = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class AlwaysProp < Prop
|
|
53
|
+
def always? = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class DeferredProp < Prop
|
|
57
|
+
def deferred? = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class OptionalProp < Prop
|
|
61
|
+
def optional? = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class LazyProp < Prop
|
|
65
|
+
# `lazy` is the historical Inertia 1 alias of `optional`. Kept so
|
|
66
|
+
# existing code reads naturally.
|
|
67
|
+
def optional? = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class MergeProp < Prop
|
|
71
|
+
def merge? = true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
def always(value = nil, &block) = AlwaysProp.new(block: block, value: value)
|
|
77
|
+
def defer(group: 'default', &block) = DeferredProp.new(block: block, group: group)
|
|
78
|
+
def optional(&block) = OptionalProp.new(block: block)
|
|
79
|
+
def lazy(&block) = LazyProp.new(block: block)
|
|
80
|
+
def merge(value = nil, &block) = MergeProp.new(block: block, value: value)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'response'
|
|
5
|
+
|
|
6
|
+
module Sinatra
|
|
7
|
+
module Inertia
|
|
8
|
+
# Sinatra helpers exposed to route handlers. Mounted by the
|
|
9
|
+
# `Sinatra::Inertia` extension.
|
|
10
|
+
module Helpers
|
|
11
|
+
# Render an Inertia response.
|
|
12
|
+
#
|
|
13
|
+
# inertia 'Todos/Index', props: { todos: -> { Todo.all } }
|
|
14
|
+
#
|
|
15
|
+
# Layout selection: the configured layout (default `:layout`) is
|
|
16
|
+
# rendered for full HTML responses. The view receives `@page_json`
|
|
17
|
+
# (an HTML-escaped JSON string ready to drop into a `data-page`
|
|
18
|
+
# attribute) and `@page` (the underlying Hash, useful for SSR or
|
|
19
|
+
# custom rendering).
|
|
20
|
+
def inertia(component, props: {}, layout: nil)
|
|
21
|
+
layout = settings.respond_to?(:inertia_layout) ? settings.inertia_layout : :layout if layout.nil?
|
|
22
|
+
|
|
23
|
+
version = current_inertia_version
|
|
24
|
+
shared = current_inertia_shared
|
|
25
|
+
encrypt = if !@inertia_encrypt_history_override.nil?
|
|
26
|
+
@inertia_encrypt_history_override == true
|
|
27
|
+
elsif settings.respond_to?(:inertia_encrypt_history)
|
|
28
|
+
settings.inertia_encrypt_history == true
|
|
29
|
+
else
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
clear = @inertia_clear_history == true
|
|
33
|
+
|
|
34
|
+
# Read errors *before* sweeping so the response carries them, then
|
|
35
|
+
# sweep immediately so the next request sees a clean slate. The
|
|
36
|
+
# sweep must happen before any further session writes that the
|
|
37
|
+
# framework might serialise on commit.
|
|
38
|
+
errors_payload = inertia_errors_payload
|
|
39
|
+
sweep_inertia_session!
|
|
40
|
+
|
|
41
|
+
response_obj = Sinatra::Inertia::Response.new(
|
|
42
|
+
component: component,
|
|
43
|
+
props: props,
|
|
44
|
+
request: request,
|
|
45
|
+
version: version,
|
|
46
|
+
url: request.fullpath,
|
|
47
|
+
encrypt_history: encrypt,
|
|
48
|
+
clear_history: clear,
|
|
49
|
+
shared: shared,
|
|
50
|
+
errors: errors_payload
|
|
51
|
+
)
|
|
52
|
+
page_hash = response_obj.to_h
|
|
53
|
+
page_json = page_hash.to_json
|
|
54
|
+
|
|
55
|
+
if inertia_request?
|
|
56
|
+
content_type 'application/json; charset=utf-8'
|
|
57
|
+
headers['X-Inertia'] = 'true'
|
|
58
|
+
headers['Vary'] = 'X-Inertia'
|
|
59
|
+
return page_json
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@page = page_hash
|
|
63
|
+
@page_json = ::Rack::Utils.escape_html(page_json)
|
|
64
|
+
erb layout, layout: false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Rails-flavored alias: `render inertia: 'Component', props: {...}`
|
|
68
|
+
# We must preserve Sinatra's `render(engine, data = nil, options = {}, locals = {}, &block)`
|
|
69
|
+
# signature for the non-inertia path, so we forward *args/**kwargs.
|
|
70
|
+
def render(*args, **kwargs, &block)
|
|
71
|
+
first = args.first
|
|
72
|
+
if args.length == 1 && first.is_a?(Hash) && first.key?(:inertia)
|
|
73
|
+
inertia(first[:inertia], props: first[:props] || {}, layout: first[:layout])
|
|
74
|
+
elsif kwargs.key?(:inertia) && args.empty?
|
|
75
|
+
inertia(kwargs[:inertia], props: kwargs[:props] || {}, layout: kwargs[:layout])
|
|
76
|
+
else
|
|
77
|
+
super(*args, **kwargs, &block)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inertia_request?
|
|
82
|
+
request.env['HTTP_X_INERTIA'] == 'true'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# CSRF token for the current request. Mounted by CSRFMiddleware
|
|
86
|
+
# (`set :inertia_csrf_protection, true` by default). Pair this with
|
|
87
|
+
# `inertia_share { { csrfToken: csrf_token } }` so the React/Vue
|
|
88
|
+
# client picks it up automatically — but note that when
|
|
89
|
+
# `Sinatra::Inertia::CSRFMiddleware` is active, the cookie + header
|
|
90
|
+
# exchange is already handled by the Inertia client; this helper is
|
|
91
|
+
# mainly for hidden-field forms or non-XHR submissions.
|
|
92
|
+
def csrf_token
|
|
93
|
+
request.env['sinatra.inertia.csrf_token']
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Shared props — runtime accessors (the `inertia_share` class DSL is
|
|
98
|
+
# in extension.rb, this is the per-request resolver).
|
|
99
|
+
def current_inertia_shared
|
|
100
|
+
blocks = settings.inertia_share_blocks || []
|
|
101
|
+
merged = {}
|
|
102
|
+
blocks.each do |b|
|
|
103
|
+
v = instance_exec(&b)
|
|
104
|
+
if v.is_a?(Hash)
|
|
105
|
+
merged = deep_merge(merged, v)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
merged
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Asset version
|
|
113
|
+
def current_inertia_version
|
|
114
|
+
v = settings.respond_to?(:inertia_version) ? settings.inertia_version : nil
|
|
115
|
+
v.respond_to?(:call) ? v.call.to_s : v.to_s
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Errors / flash session sweep (per Inertia validation pattern).
|
|
120
|
+
# Consumers call `inertia_errors(field: 'message')` before redirecting
|
|
121
|
+
# to a form route; the next request renders the form with errors and
|
|
122
|
+
# sweeps them out of the session.
|
|
123
|
+
def inertia_errors(payload = nil)
|
|
124
|
+
if payload.nil?
|
|
125
|
+
(session[:_inertia_errors] || {}).dup
|
|
126
|
+
else
|
|
127
|
+
session[:_inertia_errors] = payload
|
|
128
|
+
payload
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def inertia_clear_history!
|
|
133
|
+
@inertia_clear_history = true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def inertia_encrypt_history!(flag = true)
|
|
137
|
+
@inertia_encrypt_history_override = flag
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inertia_errors_payload
|
|
141
|
+
errors = session[:_inertia_errors]
|
|
142
|
+
return nil if errors.nil?
|
|
143
|
+
return nil if errors.respond_to?(:empty?) && errors.empty?
|
|
144
|
+
errors
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def sweep_inertia_session!
|
|
148
|
+
# Rack::Session::Cookie tracks writes by hash mutation. On some
|
|
149
|
+
# session backends (e.g. the JSON-coder cookie store homura uses
|
|
150
|
+
# under Cloudflare Workers) `delete` is a no-op for the *backing
|
|
151
|
+
# cookie* — the change isn't serialised back. Force a write by
|
|
152
|
+
# assigning nil instead, which the JSON encoder still emits as
|
|
153
|
+
# `null` and makes `inertia_errors_payload` treat the field as
|
|
154
|
+
# absent on the next visit.
|
|
155
|
+
if session.respond_to?(:[]=)
|
|
156
|
+
session[:_inertia_errors] = nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def deep_merge(a, b)
|
|
163
|
+
a.merge(b) do |_k, av, bv|
|
|
164
|
+
(av.is_a?(Hash) && bv.is_a?(Hash)) ? deep_merge(av, bv) : bv
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sinatra
|
|
4
|
+
module Inertia
|
|
5
|
+
# Rack middleware handling concerns that must run *outside* the Sinatra
|
|
6
|
+
# request cycle:
|
|
7
|
+
#
|
|
8
|
+
# * Version-mismatch detection. Per the Inertia protocol, an Inertia
|
|
9
|
+
# GET visit whose `X-Inertia-Version` header disagrees with the
|
|
10
|
+
# server's current asset version must be answered with
|
|
11
|
+
# `409 Conflict` and a `X-Inertia-Location` header pointing at the
|
|
12
|
+
# same URL — the client then performs a hard reload.
|
|
13
|
+
#
|
|
14
|
+
# * Forced 303 redirects for non-GET requests. Inertia requires the
|
|
15
|
+
# client follow the redirect with a GET, which only happens when the
|
|
16
|
+
# server uses 303 See Other (the default 302 turns into a method-
|
|
17
|
+
# preserving redirect on some browsers).
|
|
18
|
+
#
|
|
19
|
+
# The middleware is `register`-ed automatically by `Sinatra::Inertia`
|
|
20
|
+
# via `app.use`, so consumer apps don't need to wire it manually.
|
|
21
|
+
class Middleware
|
|
22
|
+
INERTIA_HEADER = 'HTTP_X_INERTIA'
|
|
23
|
+
INERTIA_VERSION_HEADER = 'HTTP_X_INERTIA_VERSION'
|
|
24
|
+
|
|
25
|
+
def initialize(app, version:)
|
|
26
|
+
@app = app
|
|
27
|
+
@version = version
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
if inertia_get?(env) && version_mismatch?(env)
|
|
32
|
+
location = env['REQUEST_URI'] || build_url(env)
|
|
33
|
+
return [
|
|
34
|
+
409,
|
|
35
|
+
{ 'X-Inertia-Location' => location, 'Vary' => 'X-Inertia' },
|
|
36
|
+
[]
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
status, headers, body = @app.call(env)
|
|
41
|
+
|
|
42
|
+
# Promote 302 → 303 for Inertia non-GET visits so the client follows
|
|
43
|
+
# the redirect with a GET. Strictly limited to `X-Inertia: true`
|
|
44
|
+
# requests: a Sinatra app may serve plain REST endpoints alongside
|
|
45
|
+
# Inertia pages, and rewriting their 302s would silently change
|
|
46
|
+
# HTTP semantics for non-Inertia clients.
|
|
47
|
+
if status == 302 && env[INERTIA_HEADER] == 'true' &&
|
|
48
|
+
%w[POST PUT PATCH DELETE].include?(env['REQUEST_METHOD'])
|
|
49
|
+
status = 303
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
[status, headers, body]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def inertia_get?(env)
|
|
58
|
+
env[INERTIA_HEADER] == 'true' && env['REQUEST_METHOD'] == 'GET'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def version_mismatch?(env)
|
|
62
|
+
current = current_version
|
|
63
|
+
return false if current.nil? || current.empty?
|
|
64
|
+
env[INERTIA_VERSION_HEADER].to_s != current
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def current_version
|
|
68
|
+
v = @version.respond_to?(:call) ? @version.call : @version
|
|
69
|
+
v.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_url(env)
|
|
73
|
+
scheme = env['rack.url_scheme'] || 'http'
|
|
74
|
+
host = env['HTTP_HOST'] || env['SERVER_NAME']
|
|
75
|
+
path = env['PATH_INFO']
|
|
76
|
+
qs = env['QUERY_STRING']
|
|
77
|
+
full = +"#{scheme}://#{host}#{path}"
|
|
78
|
+
full << "?#{qs}" if qs && !qs.empty?
|
|
79
|
+
full
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'deferred'
|
|
4
|
+
|
|
5
|
+
module Sinatra
|
|
6
|
+
module Inertia
|
|
7
|
+
# Builds the Inertia page object from a (component, props, request)
|
|
8
|
+
# tuple, applying partial-reload selection, deferred-prop excision,
|
|
9
|
+
# merge/once metadata, encrypted history flags, etc.
|
|
10
|
+
#
|
|
11
|
+
# The output is a Hash that gets serialized as JSON for X-Inertia
|
|
12
|
+
# responses, or interpolated into the layout's `data-page` attribute
|
|
13
|
+
# for full HTML responses.
|
|
14
|
+
class Response
|
|
15
|
+
attr_reader :component, :props, :request, :version, :url,
|
|
16
|
+
:encrypt_history, :clear_history, :shared, :errors
|
|
17
|
+
|
|
18
|
+
def initialize(component:, props:, request:, version:, url: nil,
|
|
19
|
+
encrypt_history: false, clear_history: false,
|
|
20
|
+
shared: {}, errors: nil)
|
|
21
|
+
@component = component
|
|
22
|
+
@props = props || {}
|
|
23
|
+
@request = request
|
|
24
|
+
@version = version.to_s
|
|
25
|
+
@url = url || request.fullpath
|
|
26
|
+
@encrypt_history = encrypt_history
|
|
27
|
+
@clear_history = clear_history
|
|
28
|
+
@shared = shared || {}
|
|
29
|
+
@errors = errors
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
merged_props = deep_merge(shared, props)
|
|
34
|
+
merged_props = merged_props.merge(errors: errors) if errors
|
|
35
|
+
|
|
36
|
+
partial = partial_request?
|
|
37
|
+
partial_data = partial_data_keys
|
|
38
|
+
partial_except = partial_except_keys
|
|
39
|
+
reset = reset_keys
|
|
40
|
+
|
|
41
|
+
resolved = {}
|
|
42
|
+
deferred_groups = {}
|
|
43
|
+
merge_keys = []
|
|
44
|
+
|
|
45
|
+
# NOTE: avoid block-based iteration here. On Opal, when this
|
|
46
|
+
# method runs as `async function` (because `await_if_promise`
|
|
47
|
+
# awaits a JS Promise), inner blocks like `Hash#each { ... }`
|
|
48
|
+
# do not natively suspend the outer function until the block
|
|
49
|
+
# body's awaits complete. A plain index-based loop preserves
|
|
50
|
+
# ordering and lets each iteration's `__await__` properly
|
|
51
|
+
# gate the next.
|
|
52
|
+
keys = merged_props.keys
|
|
53
|
+
i = 0
|
|
54
|
+
while i < keys.length
|
|
55
|
+
key = keys[i]
|
|
56
|
+
value = merged_props[key]
|
|
57
|
+
k = key.to_sym
|
|
58
|
+
included, materialized = decide(value, k, partial, partial_data, partial_except)
|
|
59
|
+
if included
|
|
60
|
+
resolved[k] = await_if_promise(materialized)
|
|
61
|
+
if value.is_a?(Prop) && value.merge? && !reset.include?(k)
|
|
62
|
+
merge_keys << k.to_s
|
|
63
|
+
end
|
|
64
|
+
elsif value.is_a?(Prop) && value.deferred?
|
|
65
|
+
(deferred_groups[value.group] ||= []) << k.to_s
|
|
66
|
+
end
|
|
67
|
+
i += 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
page = {
|
|
71
|
+
component: component,
|
|
72
|
+
props: resolved,
|
|
73
|
+
url: url,
|
|
74
|
+
version: version
|
|
75
|
+
}
|
|
76
|
+
page[:encryptHistory] = true if encrypt_history
|
|
77
|
+
page[:clearHistory] = true if clear_history
|
|
78
|
+
page[:deferredProps] = deferred_groups unless deferred_groups.empty?
|
|
79
|
+
page[:mergeProps] = merge_keys unless merge_keys.empty?
|
|
80
|
+
page
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_json(*) = to_h.to_json
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def partial_request?
|
|
88
|
+
request.env['HTTP_X_INERTIA_PARTIAL_COMPONENT'] == component
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def partial_data_keys
|
|
92
|
+
raw = request.env['HTTP_X_INERTIA_PARTIAL_DATA'].to_s
|
|
93
|
+
return nil if raw.empty?
|
|
94
|
+
raw.split(',').map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def partial_except_keys
|
|
98
|
+
raw = request.env['HTTP_X_INERTIA_PARTIAL_EXCEPT'].to_s
|
|
99
|
+
return nil if raw.empty?
|
|
100
|
+
raw.split(',').map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Inertia 2.0: `X-Inertia-Reset: a,b` tells the server "the client
|
|
104
|
+
# wants merge prop `a` and `b` to be replaced wholesale, not
|
|
105
|
+
# appended". We honour it by dropping the named keys from the
|
|
106
|
+
# outbound `mergeProps` array — the value itself is still resolved
|
|
107
|
+
# and emitted, so the client just doesn't accumulate it.
|
|
108
|
+
def reset_keys
|
|
109
|
+
raw = request.env['HTTP_X_INERTIA_RESET'].to_s
|
|
110
|
+
return [] if raw.empty?
|
|
111
|
+
raw.split(',').map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Returns [included?, resolved_value]
|
|
115
|
+
def decide(value, key, partial, partial_data, partial_except)
|
|
116
|
+
if value.is_a?(Prop)
|
|
117
|
+
if value.always?
|
|
118
|
+
return [true, value.resolve]
|
|
119
|
+
elsif value.deferred?
|
|
120
|
+
return [false, nil] unless partial && partial_data&.include?(key)
|
|
121
|
+
return [true, value.resolve]
|
|
122
|
+
elsif value.optional?
|
|
123
|
+
return [false, nil] unless partial && partial_data&.include?(key)
|
|
124
|
+
return [true, value.resolve]
|
|
125
|
+
else
|
|
126
|
+
# merge / once / plain Prop
|
|
127
|
+
if partial
|
|
128
|
+
return [false, nil] if partial_data && !partial_data.include?(key)
|
|
129
|
+
return [false, nil] if partial_except&.include?(key)
|
|
130
|
+
end
|
|
131
|
+
return [true, value.resolve]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if value.is_a?(Proc)
|
|
136
|
+
# A bare Proc/Lambda is treated as plain lazy: resolved every
|
|
137
|
+
# request, but only when included.
|
|
138
|
+
if partial
|
|
139
|
+
return [false, nil] if partial_data && !partial_data.include?(key)
|
|
140
|
+
return [false, nil] if partial_except&.include?(key)
|
|
141
|
+
end
|
|
142
|
+
return [true, value.call]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Plain value
|
|
146
|
+
if partial
|
|
147
|
+
return [false, nil] if partial_data && !partial_data.include?(key)
|
|
148
|
+
return [false, nil] if partial_except&.include?(key)
|
|
149
|
+
end
|
|
150
|
+
[true, value]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def deep_merge(a, b)
|
|
154
|
+
a.merge(b) do |_k, av, bv|
|
|
155
|
+
if av.is_a?(Hash) && bv.is_a?(Hash)
|
|
156
|
+
deep_merge(av, bv)
|
|
157
|
+
else
|
|
158
|
+
bv
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# When running on the homura Cloudflare Workers / Opal runtime, a Proc
|
|
164
|
+
# may return a JS Promise (e.g. a sequel-d1 query result). The
|
|
165
|
+
# surrounding route handler is already an async function thanks to
|
|
166
|
+
# homura's auto-await analyzer, so we can resolve the Promise here
|
|
167
|
+
# via `.__await__`. On MRI this branch is dead code (no Cloudflare
|
|
168
|
+
# constant, no js_promise?), so plain Ruby tests are unaffected.
|
|
169
|
+
def await_if_promise(value)
|
|
170
|
+
if defined?(::Cloudflare) && ::Cloudflare.respond_to?(:js_promise?) && ::Cloudflare.js_promise?(value)
|
|
171
|
+
value.__await__
|
|
172
|
+
else
|
|
173
|
+
value
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sinatra/base'
|
|
4
|
+
require_relative 'inertia/version'
|
|
5
|
+
require_relative 'inertia/errors'
|
|
6
|
+
require_relative 'inertia/deferred'
|
|
7
|
+
require_relative 'inertia/response'
|
|
8
|
+
require_relative 'inertia/middleware'
|
|
9
|
+
require_relative 'inertia/csrf_middleware'
|
|
10
|
+
require_relative 'inertia/helpers'
|
|
11
|
+
require_relative 'inertia/async_sources'
|
|
12
|
+
|
|
13
|
+
module Sinatra
|
|
14
|
+
# Inertia.js v2 protocol adapter for Sinatra.
|
|
15
|
+
#
|
|
16
|
+
# class App < Sinatra::Base
|
|
17
|
+
# register Sinatra::Inertia
|
|
18
|
+
#
|
|
19
|
+
# set :inertia_version, -> { ASSETS_VERSION }
|
|
20
|
+
# set :inertia_layout, :layout
|
|
21
|
+
#
|
|
22
|
+
# inertia_share do
|
|
23
|
+
# { auth: { user: current_user }, flash: flash_payload }
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# get '/' do
|
|
27
|
+
# inertia 'Todos/Index', props: { todos: -> { Todo.all } }
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# See README.md for the full feature matrix.
|
|
32
|
+
module Inertia
|
|
33
|
+
def self.registered(app)
|
|
34
|
+
# Default settings — consumers override with `set :inertia_*` in app.
|
|
35
|
+
app.set :inertia_version, '1' unless app.respond_to?(:inertia_version)
|
|
36
|
+
app.set :inertia_layout, :layout unless app.respond_to?(:inertia_layout)
|
|
37
|
+
app.set :inertia_encrypt_history, false unless app.respond_to?(:inertia_encrypt_history)
|
|
38
|
+
app.set :inertia_csrf_protection, true unless app.respond_to?(:inertia_csrf_protection)
|
|
39
|
+
app.set :inertia_share_blocks, []
|
|
40
|
+
|
|
41
|
+
# Mount CSRF middleware (double-submit XSRF-TOKEN cookie that
|
|
42
|
+
# @inertiajs/* clients honour). Opt-out via
|
|
43
|
+
# `set :inertia_csrf_protection, false` when the consumer ships
|
|
44
|
+
# its own (e.g. Rack::Protection::AuthenticityToken).
|
|
45
|
+
if app.settings.inertia_csrf_protection
|
|
46
|
+
app.use Sinatra::Inertia::CSRFMiddleware
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Mount protocol middleware (version mismatch + 303 redirect promotion).
|
|
50
|
+
app.use Sinatra::Inertia::Middleware, version: -> { app.settings.inertia_version }
|
|
51
|
+
|
|
52
|
+
# Class-level DSL: `inertia_share { ... }` registers a block whose
|
|
53
|
+
# return value is merged into every page's `props.shared` payload.
|
|
54
|
+
app.define_singleton_method(:inertia_share) do |&block|
|
|
55
|
+
raise ArgumentError, 'inertia_share requires a block' unless block
|
|
56
|
+
settings.inertia_share_blocks = settings.inertia_share_blocks + [block]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
app.helpers Sinatra::Inertia::Helpers
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convenience module-level constructors so code can write
|
|
63
|
+
# Inertia.defer { compute }
|
|
64
|
+
# without `Sinatra::` prefix. Defined in deferred.rb.
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register Inertia if respond_to?(:register)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Top-level shortcut so consumers can write `Inertia.defer { ... }` without
|
|
71
|
+
# prefixing every prop wrapper with `Sinatra::`. We deliberately do *not*
|
|
72
|
+
# overwrite an existing `::Inertia` constant — if your app has one, use
|
|
73
|
+
# `Sinatra::Inertia.defer` instead.
|
|
74
|
+
::Inertia = Sinatra::Inertia unless defined?(::Inertia)
|
|
75
|
+
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sinatra-inertia
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kazuhiro Homma
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: sinatra
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '5.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '3.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '5.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rack
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '4.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '2.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '4.0'
|
|
52
|
+
description: |
|
|
53
|
+
A Sinatra extension that implements the full Inertia.js v2 wire protocol:
|
|
54
|
+
page-object responses, version mismatch detection (409 + X-Inertia-Location),
|
|
55
|
+
partial reloads, deferred / lazy / always / optional / merge props,
|
|
56
|
+
encrypted history, redirect 303 handling, and error/flash session sweeps.
|
|
57
|
+
|
|
58
|
+
Pure Sinatra-compatible: depends only on `sinatra` and `rack`. Runs on MRI
|
|
59
|
+
Ruby and on the homura Cloudflare Workers + Opal stack.
|
|
60
|
+
executables: []
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- CHANGELOG.md
|
|
65
|
+
- LICENSE
|
|
66
|
+
- README.md
|
|
67
|
+
- lib/sinatra/inertia.rb
|
|
68
|
+
- lib/sinatra/inertia/async_sources.rb
|
|
69
|
+
- lib/sinatra/inertia/csrf_middleware.rb
|
|
70
|
+
- lib/sinatra/inertia/deferred.rb
|
|
71
|
+
- lib/sinatra/inertia/errors.rb
|
|
72
|
+
- lib/sinatra/inertia/helpers.rb
|
|
73
|
+
- lib/sinatra/inertia/middleware.rb
|
|
74
|
+
- lib/sinatra/inertia/response.rb
|
|
75
|
+
- lib/sinatra/inertia/version.rb
|
|
76
|
+
homepage: https://github.com/kazuph/homura
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
homepage_uri: https://github.com/kazuph/homura
|
|
81
|
+
source_code_uri: https://github.com/kazuph/homura/tree/main/gems/sinatra-inertia
|
|
82
|
+
bug_tracker_uri: https://github.com/kazuph/homura/issues
|
|
83
|
+
changelog_uri: https://github.com/kazuph/homura/blob/main/gems/sinatra-inertia/CHANGELOG.md
|
|
84
|
+
readme_uri: https://github.com/kazuph/homura/blob/main/gems/sinatra-inertia/README.md
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 3.1.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.6.9
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Sinatra adapter for Inertia.js (v2 protocol)
|
|
102
|
+
test_files: []
|