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 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sinatra
4
+ module Inertia
5
+ Error = Class.new(StandardError)
6
+ InvalidProtocol = Class.new(Error)
7
+ end
8
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sinatra
4
+ module Inertia
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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: []