salvia_rb 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +235 -0
- data/assets/javascripts/islands.js +26 -0
- data/assets/scripts/build_ssr.ts +261 -0
- data/exe/salvia +7 -0
- data/lib/salvia_rb/application.rb +431 -0
- data/lib/salvia_rb/assets.rb +74 -0
- data/lib/salvia_rb/assets_middleware.rb +46 -0
- data/lib/salvia_rb/cli.rb +1398 -0
- data/lib/salvia_rb/component.rb +74 -0
- data/lib/salvia_rb/controller.rb +277 -0
- data/lib/salvia_rb/csrf.rb +130 -0
- data/lib/salvia_rb/database.rb +176 -0
- data/lib/salvia_rb/flash.rb +43 -0
- data/lib/salvia_rb/helpers/component.rb +31 -0
- data/lib/salvia_rb/helpers/csrf.rb +47 -0
- data/lib/salvia_rb/helpers/inspector.rb +192 -0
- data/lib/salvia_rb/helpers/island.rb +143 -0
- data/lib/salvia_rb/helpers/tag.rb +90 -0
- data/lib/salvia_rb/helpers.rb +11 -0
- data/lib/salvia_rb/plugins/base.rb +59 -0
- data/lib/salvia_rb/router.rb +181 -0
- data/lib/salvia_rb/ssr/quickjs.rb +404 -0
- data/lib/salvia_rb/ssr.rb +119 -0
- data/lib/salvia_rb/test.rb +20 -0
- data/lib/salvia_rb/version.rb +5 -0
- data/lib/salvia_rb.rb +250 -0
- metadata +344 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mustermann"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Salvia
|
|
7
|
+
# Rails ライクな DSL ルーター(Mustermann ベース)
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# Salvia::Router.draw do
|
|
11
|
+
# root to: "home#index"
|
|
12
|
+
# get "/about", to: "pages#about"
|
|
13
|
+
# resources :todos, only: [:index, :create, :destroy]
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
class Router
|
|
17
|
+
HTTP_METHODS = %i[get post put patch delete].freeze
|
|
18
|
+
|
|
19
|
+
Route = Struct.new(:method, :pattern, :controller, :action, keyword_init: true) do
|
|
20
|
+
def match?(request_method, path)
|
|
21
|
+
method.to_s.upcase == request_method && pattern.match(path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def params_from(path)
|
|
25
|
+
pattern.params(path) || {}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def instance
|
|
31
|
+
@instance ||= new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def helpers
|
|
35
|
+
@helpers ||= Module.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def draw(&block)
|
|
39
|
+
instance.instance_eval(&block)
|
|
40
|
+
instance
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def recognize(request)
|
|
44
|
+
instance.recognize(request)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset!
|
|
48
|
+
@instance = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@routes = []
|
|
54
|
+
@scope = { path: "", as: [] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ルートルートを定義
|
|
58
|
+
# @example root to: "home#index"
|
|
59
|
+
def root(to:)
|
|
60
|
+
get "/", to: to, as: "root"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# HTTP メソッドルートを定義
|
|
64
|
+
HTTP_METHODS.each do |method|
|
|
65
|
+
define_method(method) do |path, to:, as: nil|
|
|
66
|
+
controller, action = to.split("#")
|
|
67
|
+
add_route(method, path, controller, action, as: as)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# RESTful リソースヘルパー
|
|
72
|
+
# @example resources :todos, only: [:index, :create, :destroy]
|
|
73
|
+
def resources(name, only: nil, except: nil, &block)
|
|
74
|
+
actions = %i[index show new create edit update destroy]
|
|
75
|
+
actions = only if only
|
|
76
|
+
actions -= except if except
|
|
77
|
+
|
|
78
|
+
prefix = @scope[:as].join("_")
|
|
79
|
+
prefix = "#{prefix}_" unless prefix.empty?
|
|
80
|
+
singular = name.to_s.singularize
|
|
81
|
+
|
|
82
|
+
resource_routes = {
|
|
83
|
+
index: [:get, "/#{name}", "#{prefix}#{name}"],
|
|
84
|
+
show: [:get, "/#{name}/:id", "#{prefix}#{singular}"],
|
|
85
|
+
new: [:get, "/#{name}/new", "#{prefix}new_#{singular}"],
|
|
86
|
+
create: [:post, "/#{name}", nil],
|
|
87
|
+
edit: [:get, "/#{name}/:id/edit", "#{prefix}edit_#{singular}"],
|
|
88
|
+
update: [:patch, "/#{name}/:id", nil],
|
|
89
|
+
destroy: [:delete, "/#{name}/:id", nil]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
controller = name.to_s
|
|
93
|
+
|
|
94
|
+
actions.each do |action|
|
|
95
|
+
method, path, as = resource_routes[action]
|
|
96
|
+
add_route(method, path, controller, action.to_s, as: as) if method
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if block_given?
|
|
100
|
+
parent_param = "#{singular}_id"
|
|
101
|
+
nested_path = "/#{name}/:#{parent_param}"
|
|
102
|
+
with_scope(path: nested_path, as: singular) do
|
|
103
|
+
block.call
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# リクエストにマッチするルートを検索
|
|
109
|
+
# @param request [Rack::Request]
|
|
110
|
+
# @return [Array<Class, String, Hash>] [コントローラークラス, アクション, パラメータ] または nil
|
|
111
|
+
def recognize(request)
|
|
112
|
+
request_method = request.request_method
|
|
113
|
+
request_method = "GET" if request_method == "HEAD"
|
|
114
|
+
path = request.path_info
|
|
115
|
+
|
|
116
|
+
@routes.each do |route|
|
|
117
|
+
next unless route.match?(request_method, path)
|
|
118
|
+
|
|
119
|
+
controller_class = resolve_controller(route.controller)
|
|
120
|
+
return nil unless controller_class
|
|
121
|
+
|
|
122
|
+
params = route.params_from(path)
|
|
123
|
+
return [controller_class, route.action, params]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 登録されたルート一覧を取得(デバッグ用)
|
|
130
|
+
def routes
|
|
131
|
+
@routes.dup
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def with_scope(options)
|
|
137
|
+
old_scope = @scope.dup
|
|
138
|
+
if options[:path]
|
|
139
|
+
@scope[:path] = File.join(@scope[:path], options[:path])
|
|
140
|
+
end
|
|
141
|
+
if options[:as]
|
|
142
|
+
@scope[:as] << options[:as]
|
|
143
|
+
end
|
|
144
|
+
yield
|
|
145
|
+
ensure
|
|
146
|
+
@scope = old_scope
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def add_route(method, path, controller, action, as: nil)
|
|
150
|
+
full_path = File.join(@scope[:path], path)
|
|
151
|
+
pattern = Mustermann.new(full_path, type: :rails)
|
|
152
|
+
|
|
153
|
+
if as
|
|
154
|
+
helper_name = "#{as}_path"
|
|
155
|
+
Salvia::Router.helpers.define_method(helper_name) do |*args|
|
|
156
|
+
params = {}
|
|
157
|
+
params = args.pop if args.last.is_a?(Hash)
|
|
158
|
+
pattern.names.each_with_index do |name, i|
|
|
159
|
+
params[name] = args[i] if i < args.length
|
|
160
|
+
end
|
|
161
|
+
pattern.expand(params)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@routes << Route.new(
|
|
166
|
+
method: method,
|
|
167
|
+
pattern: pattern,
|
|
168
|
+
controller: controller,
|
|
169
|
+
action: action
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def resolve_controller(name)
|
|
174
|
+
# "home" を "HomeController" に変換
|
|
175
|
+
class_name = "#{name.split('_').map(&:capitalize).join}Controller"
|
|
176
|
+
Object.const_get(class_name)
|
|
177
|
+
rescue NameError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Salvia
|
|
6
|
+
module SSR
|
|
7
|
+
# QuickJS SSR Engine
|
|
8
|
+
#
|
|
9
|
+
# Deno でビルドした ssr_bundle.js を読み込み、
|
|
10
|
+
# 同一プロセス内で高速に SSR を実行します。
|
|
11
|
+
#
|
|
12
|
+
# Features:
|
|
13
|
+
# - console.log を Ruby Logger に転送
|
|
14
|
+
# - SSR エラー時のオーバーレイ HTML 生成
|
|
15
|
+
# - 開発/本番モードの切り替え
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# Salvia::SSR.configure(bundle_path: "vendor/server/ssr_bundle.js")
|
|
19
|
+
# html = Salvia::SSR.render("Counter", { count: 5 })
|
|
20
|
+
#
|
|
21
|
+
class QuickJS < BaseAdapter
|
|
22
|
+
# JS から収集したログを保持
|
|
23
|
+
attr_reader :js_logs
|
|
24
|
+
|
|
25
|
+
# 最後のビルドエラー
|
|
26
|
+
attr_accessor :last_build_error
|
|
27
|
+
|
|
28
|
+
def setup!
|
|
29
|
+
require_quickjs!
|
|
30
|
+
|
|
31
|
+
@js_logs = []
|
|
32
|
+
@last_build_error = nil
|
|
33
|
+
@development = options.fetch(:development, true)
|
|
34
|
+
|
|
35
|
+
# JS ステートを初期化
|
|
36
|
+
@js_state = ""
|
|
37
|
+
|
|
38
|
+
# console.log 転送用の shim をロード
|
|
39
|
+
load_console_shim!
|
|
40
|
+
|
|
41
|
+
# Deno ビルド済みバンドルをロード
|
|
42
|
+
load_ssr_bundle!
|
|
43
|
+
|
|
44
|
+
mark_initialized!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# コンポーネントを HTML にレンダリング
|
|
48
|
+
#
|
|
49
|
+
# @param component_name [String] コンポーネント名
|
|
50
|
+
# @param props [Hash] プロパティ
|
|
51
|
+
# @return [String] レンダリングされた HTML
|
|
52
|
+
def render(component_name, props = {})
|
|
53
|
+
raise Error, "Engine not initialized" unless initialized?
|
|
54
|
+
|
|
55
|
+
# ビルドエラーがある場合は HUD を表示
|
|
56
|
+
if @last_build_error && @development
|
|
57
|
+
return build_error_html(@last_build_error)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
js_code = <<~JS
|
|
61
|
+
(function() {
|
|
62
|
+
try {
|
|
63
|
+
if (typeof globalThis.SalviaSSR === 'undefined') {
|
|
64
|
+
throw new Error('SalviaSSR runtime not loaded. Run: deno run --allow-all bin/build_ssr.ts');
|
|
65
|
+
}
|
|
66
|
+
return globalThis.SalviaSSR.render('#{escape_js(component_name)}', #{props.to_json});
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return JSON.stringify({ __ssr_error__: true, message: e.message, stack: e.stack || '' });
|
|
69
|
+
}
|
|
70
|
+
})()
|
|
71
|
+
JS
|
|
72
|
+
|
|
73
|
+
result = eval_js(js_code)
|
|
74
|
+
|
|
75
|
+
# エラーチェック
|
|
76
|
+
if result&.start_with?('{"__ssr_error__":true')
|
|
77
|
+
error_data = JSON.parse(result)
|
|
78
|
+
if @development
|
|
79
|
+
return ssr_error_overlay(component_name, error_data)
|
|
80
|
+
else
|
|
81
|
+
# 本番環境では空を返してクライアントサイドレンダリングにフォールバック
|
|
82
|
+
log_error("SSR Error in #{component_name}: #{error_data['message']}")
|
|
83
|
+
return ""
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# バンドルをリロード (開発モードでのホットリロード用)
|
|
91
|
+
def reload_bundle!
|
|
92
|
+
@js_state = ""
|
|
93
|
+
load_console_shim!
|
|
94
|
+
load_ssr_bundle!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# JS ログをフラッシュして取得
|
|
98
|
+
def flush_logs
|
|
99
|
+
logs = @js_logs.dup
|
|
100
|
+
@js_logs.clear
|
|
101
|
+
logs
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def shutdown!
|
|
105
|
+
@js_state = ""
|
|
106
|
+
@js_logs = []
|
|
107
|
+
@initialized = false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def engine_name
|
|
111
|
+
"QuickJS (Hybrid SSR Engine)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def development?
|
|
115
|
+
@development
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def eval_js(code)
|
|
121
|
+
full_code = @js_state + "\n" + code
|
|
122
|
+
result = ::Quickjs.eval_code(full_code)
|
|
123
|
+
|
|
124
|
+
# console.log の出力を処理
|
|
125
|
+
process_console_output(result)
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# console.log/error/warn を Ruby に転送する shim
|
|
131
|
+
def load_console_shim!
|
|
132
|
+
shim = <<~JS
|
|
133
|
+
// Salvia Console Shim - Captures JS logs for Ruby
|
|
134
|
+
(function() {
|
|
135
|
+
var __salvia_logs__ = [];
|
|
136
|
+
|
|
137
|
+
globalThis.console = {
|
|
138
|
+
log: function() {
|
|
139
|
+
var msg = Array.prototype.slice.call(arguments).map(function(a) {
|
|
140
|
+
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
|
141
|
+
}).join(' ');
|
|
142
|
+
__salvia_logs__.push({ level: 'log', message: msg });
|
|
143
|
+
},
|
|
144
|
+
error: function() {
|
|
145
|
+
var msg = Array.prototype.slice.call(arguments).map(function(a) {
|
|
146
|
+
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
|
147
|
+
}).join(' ');
|
|
148
|
+
__salvia_logs__.push({ level: 'error', message: msg });
|
|
149
|
+
},
|
|
150
|
+
warn: function() {
|
|
151
|
+
var msg = Array.prototype.slice.call(arguments).map(function(a) {
|
|
152
|
+
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
|
153
|
+
}).join(' ');
|
|
154
|
+
__salvia_logs__.push({ level: 'warn', message: msg });
|
|
155
|
+
},
|
|
156
|
+
info: function() {
|
|
157
|
+
var msg = Array.prototype.slice.call(arguments).map(function(a) {
|
|
158
|
+
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
|
159
|
+
}).join(' ');
|
|
160
|
+
__salvia_logs__.push({ level: 'info', message: msg });
|
|
161
|
+
},
|
|
162
|
+
debug: function() {
|
|
163
|
+
var msg = Array.prototype.slice.call(arguments).map(function(a) {
|
|
164
|
+
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
|
165
|
+
}).join(' ');
|
|
166
|
+
__salvia_logs__.push({ level: 'debug', message: msg });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
globalThis.__salvia_flush_logs__ = function() {
|
|
171
|
+
var logs = __salvia_logs__;
|
|
172
|
+
__salvia_logs__ = [];
|
|
173
|
+
return JSON.stringify(logs);
|
|
174
|
+
};
|
|
175
|
+
})();
|
|
176
|
+
JS
|
|
177
|
+
|
|
178
|
+
@js_state += shim
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ビルド済みバンドルをロード
|
|
182
|
+
def load_ssr_bundle!
|
|
183
|
+
bundle_path = options[:bundle_path] || default_bundle_path
|
|
184
|
+
|
|
185
|
+
unless File.exist?(bundle_path)
|
|
186
|
+
if @development
|
|
187
|
+
# 開発モードではバンドルなしでも起動可能(ビルド待ち)
|
|
188
|
+
log_warn("SSR bundle not found: #{bundle_path}")
|
|
189
|
+
log_warn("Run: deno run --allow-all bin/build_ssr.ts")
|
|
190
|
+
return
|
|
191
|
+
else
|
|
192
|
+
raise Error, <<~MSG
|
|
193
|
+
SSR bundle not found: #{bundle_path}
|
|
194
|
+
|
|
195
|
+
Build it with:
|
|
196
|
+
deno run --allow-all bin/build_ssr.ts
|
|
197
|
+
|
|
198
|
+
Or in production:
|
|
199
|
+
salvia ssr:build
|
|
200
|
+
MSG
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
bundle_content = File.read(bundle_path)
|
|
205
|
+
@js_state += "\n#{bundle_content}\n"
|
|
206
|
+
|
|
207
|
+
log_info("Loaded SSR bundle: #{bundle_path} (#{(File.size(bundle_path) / 1024.0).round(1)}KB)")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# console.log の出力を処理
|
|
211
|
+
def process_console_output(_result)
|
|
212
|
+
logs_json = ::Quickjs.eval_code(@js_state + "\nglobalThis.__salvia_flush_logs__()")
|
|
213
|
+
|
|
214
|
+
return if logs_json.nil? || logs_json.empty?
|
|
215
|
+
|
|
216
|
+
begin
|
|
217
|
+
logs = JSON.parse(logs_json)
|
|
218
|
+
logs.each do |log|
|
|
219
|
+
@js_logs << log
|
|
220
|
+
|
|
221
|
+
# Ruby Logger にも出力
|
|
222
|
+
case log["level"]
|
|
223
|
+
when "error"
|
|
224
|
+
log_error("JS: #{log['message']}")
|
|
225
|
+
when "warn"
|
|
226
|
+
log_warn("JS: #{log['message']}")
|
|
227
|
+
else
|
|
228
|
+
log_debug("JS: #{log['message']}")
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
rescue JSON::ParserError
|
|
232
|
+
# ignore
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# SSR エラー用のオーバーレイ HTML
|
|
237
|
+
def ssr_error_overlay(component_name, error_data)
|
|
238
|
+
<<~HTML
|
|
239
|
+
<div style="
|
|
240
|
+
background: linear-gradient(135deg, #fee 0%, #fcc 100%);
|
|
241
|
+
border: 2px solid #c00;
|
|
242
|
+
border-radius: 8px;
|
|
243
|
+
padding: 20px;
|
|
244
|
+
margin: 10px 0;
|
|
245
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
246
|
+
box-shadow: 0 4px 12px rgba(200, 0, 0, 0.15);
|
|
247
|
+
">
|
|
248
|
+
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px;">
|
|
249
|
+
<span style="font-size: 24px;">💥</span>
|
|
250
|
+
<h3 style="margin: 0; color: #900; font-size: 16px;">
|
|
251
|
+
SSR Error in <code style="background: #fff; padding: 2px 6px; border-radius: 4px;">#{escape_html(component_name)}</code>
|
|
252
|
+
</h3>
|
|
253
|
+
</div>
|
|
254
|
+
<pre style="
|
|
255
|
+
background: #1a1a2e;
|
|
256
|
+
color: #ff6b6b;
|
|
257
|
+
padding: 15px;
|
|
258
|
+
border-radius: 6px;
|
|
259
|
+
overflow-x: auto;
|
|
260
|
+
font-size: 13px;
|
|
261
|
+
line-height: 1.5;
|
|
262
|
+
margin: 0;
|
|
263
|
+
">#{escape_html(error_data['message'])}</pre>
|
|
264
|
+
#{stack_trace_html(error_data['stack'])}
|
|
265
|
+
<p style="margin: 15px 0 0 0; color: #666; font-size: 12px;">
|
|
266
|
+
💡 This error overlay is only shown in development mode.
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
HTML
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def stack_trace_html(stack)
|
|
273
|
+
return "" if stack.nil? || stack.empty?
|
|
274
|
+
|
|
275
|
+
<<~HTML
|
|
276
|
+
<details style="margin-top: 10px;">
|
|
277
|
+
<summary style="cursor: pointer; color: #666; font-size: 13px;">Stack Trace</summary>
|
|
278
|
+
<pre style="
|
|
279
|
+
background: #2a2a3e;
|
|
280
|
+
color: #aaa;
|
|
281
|
+
padding: 10px;
|
|
282
|
+
border-radius: 4px;
|
|
283
|
+
font-size: 11px;
|
|
284
|
+
margin-top: 5px;
|
|
285
|
+
overflow-x: auto;
|
|
286
|
+
">#{escape_html(stack)}</pre>
|
|
287
|
+
</details>
|
|
288
|
+
HTML
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ビルドエラー用の HUD HTML
|
|
292
|
+
def build_error_html(error_message)
|
|
293
|
+
<<~HTML
|
|
294
|
+
<div style="
|
|
295
|
+
position: fixed;
|
|
296
|
+
inset: 0;
|
|
297
|
+
background: rgba(0, 0, 0, 0.9);
|
|
298
|
+
z-index: 99999;
|
|
299
|
+
display: flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
justify-content: center;
|
|
302
|
+
padding: 40px;
|
|
303
|
+
">
|
|
304
|
+
<div style="
|
|
305
|
+
background: #1a1a2e;
|
|
306
|
+
border: 2px solid #ff6b6b;
|
|
307
|
+
border-radius: 12px;
|
|
308
|
+
padding: 30px;
|
|
309
|
+
max-width: 800px;
|
|
310
|
+
width: 100%;
|
|
311
|
+
max-height: 80vh;
|
|
312
|
+
overflow: auto;
|
|
313
|
+
">
|
|
314
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 20px;">
|
|
315
|
+
<span style="font-size: 32px;">🚨</span>
|
|
316
|
+
<h2 style="margin: 0; color: #ff6b6b; font-size: 20px;">
|
|
317
|
+
SSR Build Failed
|
|
318
|
+
</h2>
|
|
319
|
+
</div>
|
|
320
|
+
<pre style="
|
|
321
|
+
background: #0d0d1a;
|
|
322
|
+
color: #ff6b6b;
|
|
323
|
+
padding: 20px;
|
|
324
|
+
border-radius: 8px;
|
|
325
|
+
overflow-x: auto;
|
|
326
|
+
font-size: 13px;
|
|
327
|
+
line-height: 1.6;
|
|
328
|
+
margin: 0;
|
|
329
|
+
white-space: pre-wrap;
|
|
330
|
+
word-break: break-word;
|
|
331
|
+
">#{escape_html(error_message)}</pre>
|
|
332
|
+
<p style="margin: 20px 0 0 0; color: #888; font-size: 13px;">
|
|
333
|
+
Fix the error and save the file. The page will reload automatically.
|
|
334
|
+
</p>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
HTML
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def default_bundle_path
|
|
341
|
+
File.join(Dir.pwd, "vendor", "server", "ssr_bundle.js")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def require_quickjs!
|
|
345
|
+
require "quickjs"
|
|
346
|
+
rescue LoadError
|
|
347
|
+
raise Error, <<~MSG
|
|
348
|
+
quickjs gem is not installed.
|
|
349
|
+
|
|
350
|
+
Add to your Gemfile:
|
|
351
|
+
gem 'quickjs'
|
|
352
|
+
|
|
353
|
+
Then run:
|
|
354
|
+
bundle install
|
|
355
|
+
MSG
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def escape_js(str)
|
|
359
|
+
str.to_s.gsub(/['\\]/) { |c| "\\#{c}" }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def escape_html(str)
|
|
363
|
+
str.to_s
|
|
364
|
+
.gsub("&", "&")
|
|
365
|
+
.gsub("<", "<")
|
|
366
|
+
.gsub(">", ">")
|
|
367
|
+
.gsub('"', """)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# ロギングヘルパー
|
|
371
|
+
def log_info(msg)
|
|
372
|
+
if defined?(Salvia.logger)
|
|
373
|
+
Salvia.logger.info(msg)
|
|
374
|
+
else
|
|
375
|
+
puts "[SSR] #{msg}"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def log_warn(msg)
|
|
380
|
+
if defined?(Salvia.logger)
|
|
381
|
+
Salvia.logger.warn(msg)
|
|
382
|
+
else
|
|
383
|
+
puts "[SSR WARNING] #{msg}"
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def log_error(msg)
|
|
388
|
+
if defined?(Salvia.logger)
|
|
389
|
+
Salvia.logger.error(msg)
|
|
390
|
+
else
|
|
391
|
+
puts "[SSR ERROR] #{msg}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def log_debug(msg)
|
|
396
|
+
if defined?(Salvia.logger)
|
|
397
|
+
Salvia.logger.debug(msg)
|
|
398
|
+
else
|
|
399
|
+
puts "[SSR DEBUG] #{msg}" if ENV["DEBUG"]
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Salvia
|
|
4
|
+
module SSR
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class EngineNotFoundError < Error; end
|
|
7
|
+
class RenderError < Error; end
|
|
8
|
+
|
|
9
|
+
# SSR アダプターの基底クラス
|
|
10
|
+
class BaseAdapter
|
|
11
|
+
attr_reader :options
|
|
12
|
+
|
|
13
|
+
def initialize(options = {})
|
|
14
|
+
@options = options
|
|
15
|
+
@initialized = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# エンジンを初期化
|
|
19
|
+
def setup!
|
|
20
|
+
raise NotImplementedError, "#{self.class}#setup! must be implemented"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# コンポーネントをレンダリング
|
|
24
|
+
# @param component_name [String] コンポーネント名
|
|
25
|
+
# @param props [Hash] プロパティ
|
|
26
|
+
# @return [String] レンダリングされた HTML
|
|
27
|
+
def render(component_name, props = {})
|
|
28
|
+
raise NotImplementedError, "#{self.class}#render must be implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# コンポーネントを登録
|
|
32
|
+
# @param name [String] コンポーネント名
|
|
33
|
+
# @param code [String] コンポーネントの JS コード
|
|
34
|
+
def register_component(name, code)
|
|
35
|
+
raise NotImplementedError, "#{self.class}#register_component must be implemented"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# エンジンをシャットダウン
|
|
39
|
+
def shutdown!
|
|
40
|
+
# オーバーライド可能
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialized?
|
|
44
|
+
@initialized
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# エンジン名
|
|
48
|
+
def engine_name
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
protected
|
|
53
|
+
|
|
54
|
+
def mark_initialized!
|
|
55
|
+
@initialized = true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
attr_accessor :current_adapter
|
|
61
|
+
attr_accessor :last_build_error
|
|
62
|
+
|
|
63
|
+
# SSR エンジンを設定
|
|
64
|
+
# @param options [Hash] エンジンオプション
|
|
65
|
+
# @option options [String] :bundle_path SSR バンドルのパス
|
|
66
|
+
# @option options [Boolean] :development 開発モード
|
|
67
|
+
def configure(options = {})
|
|
68
|
+
require_relative "ssr/quickjs"
|
|
69
|
+
@current_adapter = QuickJS.new(options)
|
|
70
|
+
@current_adapter.setup!
|
|
71
|
+
@current_adapter
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# コンポーネントをレンダリング
|
|
75
|
+
# @param component_name [String] コンポーネント名
|
|
76
|
+
# @param props [Hash] プロパティ
|
|
77
|
+
# @return [String] レンダリングされた HTML
|
|
78
|
+
def render(component_name, props = {})
|
|
79
|
+
raise Error, "SSR not configured. Call Salvia::SSR.configure first." unless current_adapter
|
|
80
|
+
current_adapter.render(component_name, props)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# コンポーネントを登録
|
|
84
|
+
def register_component(name, code)
|
|
85
|
+
raise Error, "SSR not configured. Call Salvia::SSR.configure first." unless current_adapter
|
|
86
|
+
current_adapter.register_component(name, code)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# バンドルをリロード (開発モード用)
|
|
90
|
+
def reload!
|
|
91
|
+
return unless current_adapter
|
|
92
|
+
current_adapter.reload_bundle! if current_adapter.respond_to?(:reload_bundle!)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ビルドエラーを設定
|
|
96
|
+
def set_build_error(error)
|
|
97
|
+
@last_build_error = error
|
|
98
|
+
current_adapter.last_build_error = error if current_adapter&.respond_to?(:last_build_error=)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ビルドエラーをクリア
|
|
102
|
+
def clear_build_error
|
|
103
|
+
@last_build_error = nil
|
|
104
|
+
current_adapter.last_build_error = nil if current_adapter&.respond_to?(:last_build_error=)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# シャットダウン
|
|
108
|
+
def shutdown!
|
|
109
|
+
current_adapter&.shutdown!
|
|
110
|
+
@current_adapter = nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 設定済みか確認
|
|
114
|
+
def configured?
|
|
115
|
+
!current_adapter.nil? && current_adapter.initialized?
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|