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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +12 -0
- data/lib/sinatra/kagero/runtime.rb +67 -9
- data/lib/sinatra/kagero/version.rb +1 -1
- data/lib/sinatra/kagero.rb +9 -7
- data/runtime/kagero.js +65 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 876d65a5bc64168ea992126323cd868e567950119c698a6ec4571591bb76076a
|
|
4
|
+
data.tar.gz: f99a79047fccbdf9c6c882642f078b2bda65284b4b115903f0fcc6e55fb34533
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
data/lib/sinatra/kagero.rb
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
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}">#{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|