salvia 0.2.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.
@@ -0,0 +1,167 @@
1
+ import * as esbuild from "https://deno.land/x/esbuild@v0.24.2/mod.js";
2
+ import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.11";
3
+
4
+ // Use port 0 to let OS assign a free port
5
+ const PORT = 0;
6
+
7
+ console.log(`[Deno Init] 🚀 Salvia Sidecar starting...`);
8
+
9
+ const handler = async (request: Request): Promise<Response> => {
10
+ if (request.method !== "POST") {
11
+ return new Response("Method Not Allowed", { status: 405 });
12
+ }
13
+
14
+ try {
15
+ const body = await request.json();
16
+ const { command, params } = body;
17
+
18
+ if (command === "bundle") {
19
+ const { entryPoint, externals, format, globalName, configPath } = params;
20
+
21
+ // If format is IIFE, we need to handle externals by mapping them to globals
22
+ // But esbuild doesn't support this out of the box for IIFE with externals.
23
+ // We can use a plugin to rewrite imports to globals if they are in externals list.
24
+
25
+ // 4. Global Externals (for IIFE format)
26
+ const globalExternalsPlugin = {
27
+ name: "global-externals",
28
+ setup(build: any) {
29
+ // Preact
30
+ build.onResolve({ filter: /^preact$/ }, (args: any) => {
31
+ return { path: args.path, namespace: "global-external" };
32
+ });
33
+ // Hooks
34
+ build.onResolve({ filter: /^preact\/hooks$/ }, (args: any) => {
35
+ return { path: args.path, namespace: "global-external" };
36
+ });
37
+ // Signals
38
+ build.onResolve({ filter: /^@preact\/signals$/ }, (args: any) => {
39
+ return { path: args.path, namespace: "global-external" };
40
+ });
41
+ // JSX Runtime
42
+ build.onResolve({ filter: /^preact\/jsx-runtime$/ }, (args: any) => {
43
+ return { path: args.path, namespace: "global-external" };
44
+ });
45
+
46
+ build.onLoad({ filter: /.*/, namespace: "global-external" }, (args: any) => {
47
+ if (args.path === "preact") return { contents: "module.exports = globalThis.Preact;", loader: "js" };
48
+ if (args.path === "preact/hooks") return { contents: "module.exports = globalThis.PreactHooks;", loader: "js" };
49
+ if (args.path === "@preact/signals") return { contents: "module.exports = globalThis.PreactSignals;", loader: "js" };
50
+ if (args.path === "preact/jsx-runtime") return { contents: "module.exports = globalThis.PreactJsxRuntime;", loader: "js" };
51
+ return null;
52
+ });
53
+ },
54
+ };
55
+
56
+ const externalizePlugin = {
57
+ name: 'externalize-deps',
58
+ setup(build: any) {
59
+ build.onResolve({ filter: /.*/ }, (args: any) => {
60
+ if (externals && externals.includes(args.path)) {
61
+ return { path: args.path, external: true };
62
+ }
63
+ });
64
+ },
65
+ };
66
+
67
+ const isIIFE = format === "iife";
68
+
69
+ const plugins = [
70
+ ...denoPlugins({ configPath: configPath || `${Deno.cwd()}/salvia/deno.json` })
71
+ ];
72
+
73
+ if (isIIFE && !entryPoint.endsWith("vendor_setup.ts")) {
74
+ // IIFEの場合は、globalExternalsPlugin を使う
75
+ // ただし、denoPlugins よりも前に配置して、framework などの解決を横取りする
76
+ plugins.unshift(globalExternalsPlugin);
77
+ } else {
78
+ plugins.unshift(externalizePlugin);
79
+ }
80
+
81
+ // JIT Bundle for a specific entry point
82
+ const result = await esbuild.build({
83
+ entryPoints: [entryPoint],
84
+ bundle: true,
85
+ format: format || "esm",
86
+ globalName: globalName || undefined, // Exports will be in SalviaComponent.default
87
+ platform: "neutral",
88
+ plugins: plugins,
89
+ external: [], // We handle externals manually with plugins
90
+ write: false, // Return in memory
91
+ // 3. JSX Runtime (Automatic)
92
+ // deno.json の "preact/jsx-runtime" エイリアスを使用
93
+ jsx: "automatic",
94
+ jsxImportSource: "preact",
95
+ minify: false, // Keep it readable for debugging
96
+ });
97
+
98
+ const code = result.outputFiles[0].text;
99
+ // Deno.writeTextFileSync("debug_bundle.js", code);
100
+
101
+ return new Response(JSON.stringify({ code }), {
102
+ headers: { "Content-Type": "application/json" },
103
+ });
104
+ }
105
+
106
+ if (command === "check") {
107
+ const { entryPoint, configPath } = params;
108
+ const cmd = new Deno.Command("deno", {
109
+ args: ["check", "--config", configPath || "deno.json", entryPoint],
110
+ stdout: "piped",
111
+ stderr: "piped",
112
+ cwd: Deno.cwd(),
113
+ });
114
+
115
+ const output = await cmd.output();
116
+ const success = output.code === 0;
117
+ const message = new TextDecoder().decode(output.stderr);
118
+
119
+ return new Response(JSON.stringify({ success, message }), {
120
+ headers: { "Content-Type": "application/json" },
121
+ });
122
+ }
123
+
124
+ if (command === "fmt") {
125
+ const { entryPoint, configPath } = params;
126
+ const cmd = new Deno.Command("deno", {
127
+ args: ["fmt", "--config", configPath || "deno.json", entryPoint],
128
+ stdout: "piped",
129
+ stderr: "piped",
130
+ cwd: Deno.cwd(),
131
+ });
132
+
133
+ const output = await cmd.output();
134
+ const success = output.code === 0;
135
+ const message = new TextDecoder().decode(output.stderr);
136
+
137
+ return new Response(JSON.stringify({ success, message }), {
138
+ headers: { "Content-Type": "application/json" },
139
+ });
140
+ }
141
+
142
+ if (command === "ping") {
143
+ return new Response(JSON.stringify({ status: "pong" }));
144
+ }
145
+
146
+ return new Response("Unknown command", { status: 400 });
147
+
148
+ } catch (e) {
149
+ const err = e as Error;
150
+ console.error("Sidecar Error:", err);
151
+ return new Response(JSON.stringify({ error: err.message }), { status: 500 });
152
+ }
153
+ };
154
+
155
+ const server = Deno.serve({ port: PORT }, handler);
156
+ // Output the assigned port so Ruby can read it
157
+ console.log(`[Deno Init] Listening on http://localhost:${server.addr.port}/`);
158
+
159
+ // Handle cleanup on exit
160
+ const cleanup = () => {
161
+ console.log("🛑 Stopping Sidecar...");
162
+ esbuild.stop();
163
+ Deno.exit();
164
+ };
165
+
166
+ Deno.addSignalListener("SIGINT", cleanup);
167
+ Deno.addSignalListener("SIGTERM", cleanup);
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module SSR
5
+ module DomMock
6
+ def self.generate_shim
7
+ <<~JS
8
+ (function() {
9
+ // Mock DOM globals for SSR
10
+ globalThis.window = globalThis;
11
+ globalThis.self = globalThis;
12
+ globalThis.addEventListener = function() {};
13
+ globalThis.removeEventListener = function() {};
14
+ globalThis.document = {
15
+ createElement: function() { return {}; },
16
+ createTextNode: function() { return {}; },
17
+ addEventListener: function() { },
18
+ removeEventListener: function() { },
19
+ head: {},
20
+ body: {},
21
+ documentElement: {
22
+ addEventListener: function() { },
23
+ removeEventListener: function() { }
24
+ }
25
+ };
26
+ globalThis.HTMLFormElement = class {};
27
+ globalThis.HTMLElement = class {};
28
+ globalThis.Element = class {};
29
+ globalThis.Node = class {};
30
+ globalThis.Event = class {};
31
+ globalThis.CustomEvent = class {};
32
+ globalThis.URL = class {
33
+ constructor(url) { this.href = url; }
34
+ static createObjectURL() { return ""; }
35
+ static revokeObjectURL() { }
36
+ };
37
+ globalThis.requestAnimationFrame = function(cb) { return setTimeout(cb, 0); };
38
+ globalThis.cancelAnimationFrame = function(id) { clearTimeout(id); };
39
+ globalThis.navigator = { userAgent: 'SalviaSSR' };
40
+ globalThis.location = { href: 'http://localhost' };
41
+ })();
42
+ JS
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Salvia
6
+ module SSR
7
+ class QuickJS < BaseAdapter
8
+ attr_reader :js_logs
9
+ attr_accessor :last_build_error
10
+
11
+ def setup!
12
+ require_quickjs!
13
+
14
+ @js_logs = []
15
+ @last_build_error = nil
16
+ @development = options.fetch(:development, true)
17
+
18
+ @vm = ::Quickjs::VM.new
19
+
20
+ load_console_shim!
21
+
22
+ if @development
23
+ load_vendor_bundle!
24
+ else
25
+ load_ssr_bundle!
26
+ end
27
+
28
+ mark_initialized!
29
+ end
30
+
31
+ def shutdown!
32
+ @vm = nil
33
+ @js_logs = []
34
+ @initialized = false
35
+ Salvia::Compiler.shutdown if @development
36
+ end
37
+
38
+ def render(component_name, props = {})
39
+ log_info("[Salvia] Rendering #{component_name}") if @development
40
+ raise Error, "Engine not initialized" unless initialized?
41
+
42
+ if @development
43
+ render_jit(component_name, props)
44
+ else
45
+ render_production(component_name, props)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def vm
52
+ @vm
53
+ end
54
+
55
+ def render_jit(component_name, props)
56
+ begin
57
+ path = resolve_path(component_name)
58
+ unless path
59
+ raise Error, "Component not found: #{component_name}"
60
+ end
61
+
62
+ # Bundle component
63
+ js_code = Salvia::Compiler.bundle(
64
+ path,
65
+ externals: ["preact", "preact/hooks", "preact-render-to-string"],
66
+ format: "iife",
67
+ global_name: "SalviaComponent"
68
+ )
69
+
70
+ # Async Type Check
71
+ Thread.new do
72
+ begin
73
+ result = Salvia::Compiler.check(path)
74
+ unless result["success"]
75
+ log_warn("Type Check Failed for #{component_name}:\n#{result["message"]}")
76
+ end
77
+ rescue => e
78
+ log_debug("Type Check Error: #{e.message}")
79
+ end
80
+ end
81
+
82
+ eval_js(js_code)
83
+
84
+ # Render
85
+ render_script = <<~JS
86
+ (function() {
87
+ try {
88
+ const Component = SalviaComponent.default;
89
+ if (!Component) throw new Error("Component default export not found in " + "#{escape_js(component_name)}");
90
+ const vnode = h(Component, #{props.to_json});
91
+ const html = renderToString(vnode);
92
+ return JSON.stringify(html);
93
+ } catch (e) {
94
+ return JSON.stringify({ __ssr_error__: true, message: e.message, stack: e.stack || '' });
95
+ }
96
+ })()
97
+ JS
98
+
99
+ result = eval_js(render_script)
100
+
101
+ begin
102
+ parsed = JSON.parse(result)
103
+ if parsed.is_a?(Hash) && parsed["__ssr_error__"]
104
+ @last_build_error = parsed['message']
105
+ return ssr_error_overlay(component_name, parsed)
106
+ end
107
+ return parsed
108
+ rescue JSON::ParserError
109
+ if result.nil?
110
+ log_error("Render result is nil")
111
+ return nil
112
+ end
113
+ return result
114
+ end
115
+ rescue => e
116
+ log_error("Render JIT Error: #{e.message}")
117
+ @last_build_error = e.message
118
+ return build_error_html(e.message)
119
+ end
120
+ end
121
+
122
+ def render_production(component_name, props)
123
+ js_code = <<~JS
124
+ (function() {
125
+ try {
126
+ if (typeof globalThis.SalviaSSR === 'undefined') {
127
+ throw new Error('SalviaSSR runtime not loaded.');
128
+ }
129
+ const html = globalThis.SalviaSSR.render('#{escape_js(component_name)}', #{props.to_json});
130
+ return JSON.stringify(html);
131
+ } catch (e) {
132
+ return JSON.stringify({ __ssr_error__: true, message: e.message, stack: e.stack || '' });
133
+ }
134
+ })()
135
+ JS
136
+
137
+ result = eval_js(js_code)
138
+
139
+ begin
140
+ parsed = JSON.parse(result)
141
+ if parsed.is_a?(Hash) && parsed["__ssr_error__"]
142
+ return ssr_error_overlay(component_name, parsed)
143
+ end
144
+ return parsed
145
+ rescue JSON::ParserError
146
+ return result
147
+ end
148
+ end
149
+
150
+ def eval_js(code)
151
+ result = @vm.eval_code(code)
152
+ process_console_output
153
+ result
154
+ rescue => e
155
+ process_console_output
156
+ raise e
157
+ end
158
+
159
+ def load_console_shim!
160
+ shim = generate_console_shim
161
+ @vm.eval_code(shim)
162
+ @vm.eval_code(Salvia::SSR::DomMock.generate_shim)
163
+ rescue => e
164
+ log_error("Failed to load console shim: #{e.message}")
165
+ end
166
+
167
+ def load_vendor_bundle!
168
+ # Use internal vendor_setup.ts for Zero Config
169
+ vendor_path = File.expand_path("../../../assets/scripts/vendor_setup.ts", __dir__)
170
+
171
+ if File.exist?(vendor_path)
172
+ code = Salvia::Compiler.bundle(vendor_path, format: "iife")
173
+ eval_js(code)
174
+ log_info("Loaded Vendor bundle (Internal)")
175
+ else
176
+ log_error("Internal vendor_setup.ts not found at #{vendor_path}")
177
+ end
178
+ rescue => e
179
+ log_error("Failed to load vendor bundle: #{e.message}")
180
+ end
181
+
182
+ def load_ssr_bundle!
183
+ bundle_path = options[:bundle_path] || default_bundle_path
184
+
185
+ unless File.exist?(bundle_path)
186
+ raise Error, "SSR bundle not found: #{bundle_path}"
187
+ end
188
+
189
+ bundle_content = File.read(bundle_path)
190
+ @vm.eval_code(bundle_content)
191
+ log_info("Loaded SSR bundle: #{bundle_path}")
192
+ end
193
+
194
+ def resolve_path(name)
195
+ Salvia::Core::PathResolver.resolve(name)
196
+ end
197
+
198
+ def process_console_output
199
+ logs_json = @vm.eval_code("globalThis.__salvia_flush_logs__()")
200
+ return if logs_json.nil? || logs_json.empty?
201
+
202
+ begin
203
+ logs = JSON.parse(logs_json)
204
+ logs.each do |log|
205
+ @js_logs << log
206
+ case log["level"]
207
+ when "error"
208
+ log_error("JS: #{log['message']}")
209
+ when "warn"
210
+ log_warn("JS: #{log['message']}")
211
+ else
212
+ log_debug("JS: #{log['message']}")
213
+ end
214
+ end
215
+ rescue JSON::ParserError
216
+ end
217
+ end
218
+
219
+ def generate_console_shim
220
+ <<~JS
221
+ (function() {
222
+ var __salvia_logs__ = [];
223
+ globalThis.console = {
224
+ log: function() { __salvia_logs__.push({ level: 'log', message: Array.from(arguments).join(' ') }); },
225
+ error: function() { __salvia_logs__.push({ level: 'error', message: Array.from(arguments).join(' ') }); },
226
+ warn: function() { __salvia_logs__.push({ level: 'warn', message: Array.from(arguments).join(' ') }); },
227
+ info: function() { __salvia_logs__.push({ level: 'info', message: Array.from(arguments).join(' ') }); },
228
+ debug: function() { __salvia_logs__.push({ level: 'debug', message: Array.from(arguments).join(' ') }); }
229
+ };
230
+ globalThis.__salvia_flush_logs__ = function() {
231
+ var logs = __salvia_logs__;
232
+ __salvia_logs__ = [];
233
+ return JSON.stringify(logs);
234
+ };
235
+ })();
236
+ JS
237
+ end
238
+
239
+ def ssr_error_overlay(component_name, error_data)
240
+ <<~HTML
241
+ <div style="background:#fee;border:2px solid #c00;padding:20px;margin:10px 0;">
242
+ <h3>SSR Error in #{escape_html(component_name)}</h3>
243
+ <pre>#{escape_html(error_data['message'])}</pre>
244
+ <details><summary>Stack Trace</summary><pre>#{escape_html(error_data['stack'])}</pre></details>
245
+ </div>
246
+ HTML
247
+ end
248
+
249
+ def build_error_html(error_message)
250
+ <<~HTML
251
+ <div style="background:#1a1a2e;color:#ff6b6b;padding:20px;position:fixed;inset:0;z-index:9999;">
252
+ <h2>SSR Build Failed</h2>
253
+ <pre>#{escape_html(error_message)}</pre>
254
+ </div>
255
+ HTML
256
+ end
257
+
258
+ def default_bundle_path
259
+ File.join(Dir.pwd, "salvia", "server", "ssr_bundle.js")
260
+ end
261
+
262
+ def require_quickjs!
263
+ require "quickjs"
264
+ rescue LoadError
265
+ raise Error, "quickjs gem is not installed."
266
+ end
267
+
268
+ def escape_js(str)
269
+ str.to_s.gsub(/['\\]/) { |c| "\\#{c}" }
270
+ end
271
+
272
+ def escape_html(str)
273
+ str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
274
+ end
275
+
276
+ def log_info(msg); defined?(Salvia.logger) ? Salvia.logger.info(msg) : puts("[SSR] #{msg}"); end
277
+ def log_warn(msg); defined?(Salvia.logger) ? Salvia.logger.warn(msg) : puts("[SSR WARNING] #{msg}"); end
278
+ def log_error(msg); defined?(Salvia.logger) ? Salvia.logger.error(msg) : puts("[SSR ERROR] #{msg}"); end
279
+ def log_debug(msg); defined?(Salvia.logger) ? Salvia.logger.debug(msg) : puts("[SSR DEBUG] #{msg}") if ENV["DEBUG"]; end
280
+ end
281
+ end
282
+ end
data/lib/salvia/ssr.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ module SSR
5
+ class Error < Salvia::Error; 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ VERSION = "0.2.0"
5
+ end
data/lib/salvia.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "salvia/version"
4
+
5
+ require "rack"
6
+ require "zeitwerk"
7
+ require "logger"
8
+
9
+ loader = Zeitwerk::Loader.for_gem
10
+ loader.ignore("#{__dir__}/salvia/version.rb")
11
+ loader.inflector.inflect(
12
+ "salvia" => "Salvia",
13
+ "cli" => "CLI",
14
+ "ssr" => "SSR"
15
+ )
16
+ loader.setup
17
+
18
+ module Salvia
19
+ # Alias Core classes for backward compatibility
20
+ Error = Core::Error
21
+ Configuration = Core::Configuration
22
+
23
+ class << self
24
+ attr_accessor :root, :env, :logger
25
+
26
+ def logger
27
+ @logger ||= Logger.new(STDOUT)
28
+ end
29
+
30
+ def config
31
+ @config ||= Core::Configuration.new
32
+ end
33
+
34
+ def configure
35
+ yield config if block_given?
36
+
37
+ # Initialize SSR engine
38
+ if defined?(Salvia::SSR)
39
+ Salvia::SSR.configure(
40
+ bundle_path: config.ssr_bundle_path,
41
+ development: development?
42
+ )
43
+ end
44
+ end
45
+
46
+ def root
47
+ config.root
48
+ end
49
+
50
+ def env
51
+ @env ||= ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
52
+ end
53
+
54
+ def development?
55
+ env == "development"
56
+ end
57
+
58
+ def production?
59
+ env == "production"
60
+ end
61
+
62
+ def test?
63
+ env == "test"
64
+ end
65
+ end
66
+ end
67
+
68
+ require "salvia/railtie" if defined?(Rails)