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.
@@ -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("&", "&amp;")
365
+ .gsub("<", "&lt;")
366
+ .gsub(">", "&gt;")
367
+ .gsub('"', "&quot;")
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