sinatra-kagero 1.0.2 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f7387c7811a71ac630427e87886f19c0c4600d2a6f94afc8bb356395cb5af53
4
- data.tar.gz: 607a20b6823c5905d807d95c119951b9be974edf50f3ca089c726482e631872d
3
+ metadata.gz: 876d65a5bc64168ea992126323cd868e567950119c698a6ec4571591bb76076a
4
+ data.tar.gz: f99a79047fccbdf9c6c882642f078b2bda65284b4b115903f0fcc6e55fb34533
5
5
  SHA512:
6
- metadata.gz: 5e905a9e0543d1f1b08e53a2d45eff24a48b367b45a0a57f3b342825470d5ef96677f4a20f3a8947bf956c1c8171f5ab8d0671edef4c2d919507682e943ce717
7
- data.tar.gz: 6f48eb473d1d374ac5937b9da0ecd80abecde61469b1cac88f05d4ef6cccbacc947133908794230392addee7ef48ef3bd9c792ff982587f6c66971febaea59f0
6
+ metadata.gz: e0ba41fda2a59fd81d000f38705ede50c47b0ba515b0044d7ff4a9b73fbe497f5416a6caba8f813d779c9839abb628021ae6def7c7118926cf1a5418c270c771
7
+ data.tar.gz: 76d31deef039dba860f913ea43bff2d8fd55a5d77578228d3bc038a24268572fa365a8c0b23be2402dfb2386e1b4c5100f3fc694403fbf2314cd6453807a981f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ - Preserve Kagero HTML on partial reloads and synchronize title and history.
6
+ - Send the CSRF header for same-origin unsafe visits and show visit failures.
7
+ - Accept valid Inertia page JSON even when the response header is missing.
8
+ - With CSRF protection enabled, ordinary form submissions without JavaScript
9
+ receive HTTP 403 because they cannot send the required header.
10
+ - Partial reloads replace the full HTML while retaining omitted props.
11
+
3
12
  ## 1.0.2 — 2026-06-26
4
13
 
5
14
  - Submit regular Kagero forms as UTF-8 URL-encoded bodies, preserving
data/README.md CHANGED
@@ -34,3 +34,15 @@ end
34
34
  Kagero intentionally keeps JavaScript out of the main application authoring
35
35
  surface. The client runtime exists, but route handlers, pages, props, forms,
36
36
  validation, and persistence remain Ruby.
37
+
38
+ ## CSRF protection and partial reloads
39
+
40
+ With Inertia CSRF protection enabled, Kagero sends the `XSRF-TOKEN` cookie
41
+ in the `X-XSRF-TOKEN` header for same-origin unsafe requests. A normal form
42
+ submission without JavaScript cannot send this header and receives HTTP 403.
43
+
44
+ Partial reloads select the props transferred; Kagero renders and replaces the
45
+ whole HTML page. Props omitted from the partial response retain their old values.
46
+
47
+ Direct `Kagero.visit` calls navigate to external origins only for GET; other
48
+ methods show an error and do not send a request.
@@ -3,8 +3,9 @@
3
3
  module Sinatra
4
4
  module Kagero
5
5
  module Runtime
6
- SOURCE = <<~JS
6
+ SOURCE = <<~'JS'
7
7
  const root = document.querySelector("[data-kagero-root]");
8
+
8
9
  let currentPage = null;
9
10
  let currentScroll = { left: 0, top: 0 };
10
11
  let pendingVisits = 0;
@@ -96,13 +97,19 @@ module Sinatra
96
97
  }
97
98
  }
98
99
 
99
- function applyPage(page, { replace = false, preserveScroll = false } = {}) {
100
+ function renderPage(page) {
100
101
  if (!root) return;
101
102
  const html = pageHtml(page);
102
103
  if (typeof html === "string") root.innerHTML = html;
103
104
  root.setAttribute("data-page", JSON.stringify(page));
104
105
  currentPage = page;
106
+ if (page.props.kagero && typeof page.props.kagero.title === "string") {
107
+ document.title = page.props.kagero.title;
108
+ }
109
+ }
105
110
 
111
+ function applyPage(page, { replace = false, preserveScroll = false } = {}) {
112
+ renderPage(page);
106
113
  const state = { kagero: true, page, scroll: preserveScroll ? currentScroll : { left: 0, top: 0 } };
107
114
  if (replace) history.replaceState(state, "", page.url);
108
115
  else history.pushState(state, "", page.url);
@@ -110,8 +117,25 @@ module Sinatra
110
117
  if (!preserveScroll) window.scrollTo(0, 0);
111
118
  }
112
119
 
120
+ function showFailure() {
121
+ let notice = document.getElementById("kagero-visit-error");
122
+ if (!notice) {
123
+ notice = document.createElement("div");
124
+ notice.id = "kagero-visit-error";
125
+ notice.setAttribute("role", "alert");
126
+ document.body.prepend(notice);
127
+ }
128
+ notice.textContent = "操作に失敗しました。もう一度お試しください。";
129
+ }
130
+
113
131
  async function visit(url, options = {}) {
114
- rememberScroll();
132
+ const method = (options.method || "GET").toUpperCase();
133
+ const targetUrl = new URL(url, location.href);
134
+ if (targetUrl.origin !== location.origin) {
135
+ if (method === "GET") location.href = targetUrl.href;
136
+ else showFailure();
137
+ return;
138
+ }
115
139
  startLoading();
116
140
  const headers = new Headers(options.headers || {});
117
141
  headers.set("X-Inertia", "true");
@@ -119,27 +143,61 @@ module Sinatra
119
143
  headers.set("X-Requested-With", "XMLHttpRequest");
120
144
  headers.set("Accept", "application/json, text/html;q=0.9");
121
145
 
146
+ if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
147
+ const cookie = document.cookie.split("; ").find((entry) => entry.startsWith("XSRF-TOKEN="));
148
+ if (cookie) headers.set("X-XSRF-TOKEN", decodeURIComponent(cookie.slice("XSRF-TOKEN=".length)));
149
+ }
150
+
122
151
  try {
123
152
  const response = await fetch(url, {
124
- method: options.method || "GET",
153
+ method,
125
154
  body: options.body,
126
155
  headers,
127
156
  credentials: "same-origin",
157
+ mode: "same-origin",
128
158
  redirect: "follow"
129
159
  });
130
160
 
161
+ if (new URL(response.url).origin !== location.origin) throw new Error("Cross-origin response");
162
+
131
163
  if (response.status === 409 && response.headers.get("X-Inertia-Location")) {
132
- location.href = response.headers.get("X-Inertia-Location");
164
+ const destination = new URL(response.headers.get("X-Inertia-Location"), location.href);
165
+ const current = new URL(location.href);
166
+ destination.hash = "";
167
+ current.hash = "";
168
+ if (destination.href === current.href) location.reload();
169
+ else location.href = response.headers.get("X-Inertia-Location");
133
170
  return;
134
171
  }
135
172
 
136
173
  if (!response.ok) throw new Error(`Kagero visit failed: ${response.status}`);
137
174
 
138
- const page = await response.json();
175
+ let page = await response.json();
176
+ if (!page || typeof page.component !== "string" || typeof page.url !== "string" ||
177
+ typeof page.version !== "string" || !page.props || typeof page.props !== "object" ||
178
+ Array.isArray(page.props) || !page.props.kagero ||
179
+ typeof page.props.kagero.html !== "string" || typeof page.props.kagero.title !== "string") {
180
+ throw new Error("Invalid page");
181
+ }
182
+ const pageUrl = new URL(page.url, location.href);
183
+ if (!["http:", "https:"].includes(pageUrl.protocol) || pageUrl.origin !== location.origin) {
184
+ throw new Error("Invalid page URL");
185
+ }
186
+ const partialComponent = headers.get("X-Inertia-Partial-Component");
187
+ if (partialComponent) {
188
+ if (!currentPage || partialComponent !== currentPage.component) return;
189
+ if (partialComponent === page.component) {
190
+ page = { ...page, props: { ...currentPage.props, ...page.props } };
191
+ }
192
+ }
193
+ rememberScroll();
194
+ document.getElementById("kagero-visit-error")?.remove();
139
195
  applyPage(page, {
140
196
  replace: options.replace === true,
141
197
  preserveScroll: options.preserveScroll === true
142
198
  });
199
+ } catch (error) {
200
+ showFailure();
143
201
  } finally {
144
202
  finishLoading();
145
203
  }
@@ -176,6 +234,7 @@ module Sinatra
176
234
 
177
235
  const link = target.closest("a[data-kagero]");
178
236
  if (link) {
237
+ if (new URL(link.href, location.href).origin !== location.origin) return;
179
238
  event.preventDefault();
180
239
  visit(link.href, {
181
240
  replace: link.dataset.kageroReplace === "true",
@@ -202,6 +261,7 @@ module Sinatra
202
261
  if (!target || !target.closest) return;
203
262
  const form = target.closest("form[data-kagero]");
204
263
  if (!form) return;
264
+ if (new URL(form.action, location.href).origin !== location.origin) return;
205
265
 
206
266
  event.preventDefault();
207
267
  visit(formUrl(form), {
@@ -213,9 +273,7 @@ module Sinatra
213
273
 
214
274
  window.addEventListener("popstate", (event) => {
215
275
  if (event.state && event.state.kagero && event.state.page) {
216
- currentPage = event.state.page;
217
- const html = pageHtml(currentPage);
218
- if (typeof html === "string") root.innerHTML = html;
276
+ renderPage(event.state.page);
219
277
  const scroll = event.state.scroll || { left: 0, top: 0 };
220
278
  window.scrollTo(scroll.left, scroll.top);
221
279
  } else {
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sinatra
4
4
  module Kagero
5
- VERSION = "1.0.2"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
@@ -43,11 +43,13 @@ module Sinatra
43
43
  body_html = page_instance.call
44
44
 
45
45
  response_props = page_props.merge(
46
- kagero: {
47
- component: component,
48
- html: body_html,
49
- title: page_instance.kagero_title
50
- }
46
+ kagero: Sinatra::Inertia.always(
47
+ {
48
+ component: component,
49
+ html: body_html,
50
+ title: page_instance.kagero_title
51
+ }
52
+ )
51
53
  )
52
54
 
53
55
  if inertia_request?
@@ -72,11 +74,11 @@ module Sinatra
72
74
  <head>
73
75
  <meta charset="utf-8">
74
76
  <meta name="viewport" content="width=device-width,initial-scale=1">
75
- <title>#{::Rack::Utils.escape_html(props.dig(:kagero, :title).to_s)}</title>
77
+ <title>#{::Rack::Utils.escape_html(page_hash.dig(:props, :kagero, :title).to_s)}</title>
76
78
  <script type="module">#{Sinatra::Kagero::Runtime::SOURCE}</script>
77
79
  </head>
78
80
  <body>
79
- <main id="#{::Rack::Utils.escape_html(settings.kagero_root_id)}" data-kagero-root data-page="#{page_json}">#{props.dig(:kagero, :html)}</main>
81
+ <main id="#{::Rack::Utils.escape_html(settings.kagero_root_id)}" data-kagero-root data-page="#{page_json}">#{page_hash.dig(:props, :kagero, :html)}</main>
80
82
  </body>
81
83
  </html>
82
84
  HTML
data/runtime/kagero.js CHANGED
@@ -91,13 +91,19 @@ function rememberScroll() {
91
91
  }
92
92
  }
93
93
 
94
- function applyPage(page, { replace = false, preserveScroll = false } = {}) {
94
+ function renderPage(page) {
95
95
  if (!root) return;
96
96
  const html = pageHtml(page);
97
97
  if (typeof html === "string") root.innerHTML = html;
98
98
  root.setAttribute("data-page", JSON.stringify(page));
99
99
  currentPage = page;
100
+ if (page.props.kagero && typeof page.props.kagero.title === "string") {
101
+ document.title = page.props.kagero.title;
102
+ }
103
+ }
100
104
 
105
+ function applyPage(page, { replace = false, preserveScroll = false } = {}) {
106
+ renderPage(page);
101
107
  const state = { kagero: true, page, scroll: preserveScroll ? currentScroll : { left: 0, top: 0 } };
102
108
  if (replace) history.replaceState(state, "", page.url);
103
109
  else history.pushState(state, "", page.url);
@@ -105,8 +111,25 @@ function applyPage(page, { replace = false, preserveScroll = false } = {}) {
105
111
  if (!preserveScroll) window.scrollTo(0, 0);
106
112
  }
107
113
 
114
+ function showFailure() {
115
+ let notice = document.getElementById("kagero-visit-error");
116
+ if (!notice) {
117
+ notice = document.createElement("div");
118
+ notice.id = "kagero-visit-error";
119
+ notice.setAttribute("role", "alert");
120
+ document.body.prepend(notice);
121
+ }
122
+ notice.textContent = "操作に失敗しました。もう一度お試しください。";
123
+ }
124
+
108
125
  async function visit(url, options = {}) {
109
- rememberScroll();
126
+ const method = (options.method || "GET").toUpperCase();
127
+ const targetUrl = new URL(url, location.href);
128
+ if (targetUrl.origin !== location.origin) {
129
+ if (method === "GET") location.href = targetUrl.href;
130
+ else showFailure();
131
+ return;
132
+ }
110
133
  startLoading();
111
134
  const headers = new Headers(options.headers || {});
112
135
  headers.set("X-Inertia", "true");
@@ -114,27 +137,61 @@ async function visit(url, options = {}) {
114
137
  headers.set("X-Requested-With", "XMLHttpRequest");
115
138
  headers.set("Accept", "application/json, text/html;q=0.9");
116
139
 
140
+ if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
141
+ const cookie = document.cookie.split("; ").find((entry) => entry.startsWith("XSRF-TOKEN="));
142
+ if (cookie) headers.set("X-XSRF-TOKEN", decodeURIComponent(cookie.slice("XSRF-TOKEN=".length)));
143
+ }
144
+
117
145
  try {
118
146
  const response = await fetch(url, {
119
- method: options.method || "GET",
147
+ method,
120
148
  body: options.body,
121
149
  headers,
122
150
  credentials: "same-origin",
151
+ mode: "same-origin",
123
152
  redirect: "follow"
124
153
  });
125
154
 
155
+ if (new URL(response.url).origin !== location.origin) throw new Error("Cross-origin response");
156
+
126
157
  if (response.status === 409 && response.headers.get("X-Inertia-Location")) {
127
- location.href = response.headers.get("X-Inertia-Location");
158
+ const destination = new URL(response.headers.get("X-Inertia-Location"), location.href);
159
+ const current = new URL(location.href);
160
+ destination.hash = "";
161
+ current.hash = "";
162
+ if (destination.href === current.href) location.reload();
163
+ else location.href = response.headers.get("X-Inertia-Location");
128
164
  return;
129
165
  }
130
166
 
131
167
  if (!response.ok) throw new Error(`Kagero visit failed: ${response.status}`);
132
168
 
133
- const page = await response.json();
169
+ let page = await response.json();
170
+ if (!page || typeof page.component !== "string" || typeof page.url !== "string" ||
171
+ typeof page.version !== "string" || !page.props || typeof page.props !== "object" ||
172
+ Array.isArray(page.props) || !page.props.kagero ||
173
+ typeof page.props.kagero.html !== "string" || typeof page.props.kagero.title !== "string") {
174
+ throw new Error("Invalid page");
175
+ }
176
+ const pageUrl = new URL(page.url, location.href);
177
+ if (!["http:", "https:"].includes(pageUrl.protocol) || pageUrl.origin !== location.origin) {
178
+ throw new Error("Invalid page URL");
179
+ }
180
+ const partialComponent = headers.get("X-Inertia-Partial-Component");
181
+ if (partialComponent) {
182
+ if (!currentPage || partialComponent !== currentPage.component) return;
183
+ if (partialComponent === page.component) {
184
+ page = { ...page, props: { ...currentPage.props, ...page.props } };
185
+ }
186
+ }
187
+ rememberScroll();
188
+ document.getElementById("kagero-visit-error")?.remove();
134
189
  applyPage(page, {
135
190
  replace: options.replace === true,
136
191
  preserveScroll: options.preserveScroll === true
137
192
  });
193
+ } catch (error) {
194
+ showFailure();
138
195
  } finally {
139
196
  finishLoading();
140
197
  }
@@ -171,6 +228,7 @@ document.addEventListener("click", (event) => {
171
228
 
172
229
  const link = target.closest("a[data-kagero]");
173
230
  if (link) {
231
+ if (new URL(link.href, location.href).origin !== location.origin) return;
174
232
  event.preventDefault();
175
233
  visit(link.href, {
176
234
  replace: link.dataset.kageroReplace === "true",
@@ -197,6 +255,7 @@ document.addEventListener("submit", (event) => {
197
255
  if (!target || !target.closest) return;
198
256
  const form = target.closest("form[data-kagero]");
199
257
  if (!form) return;
258
+ if (new URL(form.action, location.href).origin !== location.origin) return;
200
259
 
201
260
  event.preventDefault();
202
261
  visit(formUrl(form), {
@@ -208,9 +267,7 @@ document.addEventListener("submit", (event) => {
208
267
 
209
268
  window.addEventListener("popstate", (event) => {
210
269
  if (event.state && event.state.kagero && event.state.page) {
211
- currentPage = event.state.page;
212
- const html = pageHtml(currentPage);
213
- if (typeof html === "string") root.innerHTML = html;
270
+ renderPage(event.state.page);
214
271
  const scroll = event.state.scroll || { left: 0, top: 0 };
215
272
  window.scrollTo(scroll.left, scroll.top);
216
273
  } else {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-kagero
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kazuhiro Homma