salvia 0.2.0 → 0.2.2
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/.vscode/settings.json +4 -0
- data/Gemfile +8 -0
- data/Rakefile +12 -0
- data/assets/islands/Counter.tsx +2 -2
- data/assets/javascripts/islands.js +2 -1
- data/assets/scripts/build.ts +91 -41
- data/assets/scripts/deno.json +3 -3
- data/assets/scripts/sidecar.ts +47 -23
- data/assets/scripts/vendor_setup.ts +16 -6
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/salvia/cli.rb +40 -9
- data/lib/salvia/core/import_map.rb +120 -20
- data/lib/salvia/helpers/island.rb +8 -59
- data/lib/salvia/server/dev_server.rb +23 -9
- data/lib/salvia/server/sidecar.rb +72 -35
- data/lib/salvia/ssr/dom_mock.rb +11 -3
- data/lib/salvia/ssr/quickjs.rb +97 -34
- data/lib/salvia/ssr.rb +67 -5
- data/lib/salvia/version.rb +1 -1
- metadata +11 -6
- data/README.md +0 -152
|
@@ -5,32 +5,132 @@ require "json"
|
|
|
5
5
|
module Salvia
|
|
6
6
|
module Core
|
|
7
7
|
class ImportMap
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
def self.generate(additional_map = {})
|
|
9
|
+
new.generate(additional_map)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def generate(additional_map = {})
|
|
13
|
+
map = { "imports" => default_imports }
|
|
14
|
+
|
|
15
|
+
# Merge imports from deno.json
|
|
16
|
+
deno_imports = load_deno_imports
|
|
17
|
+
map["imports"].merge!(deno_imports) if deno_imports
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return {} unless path
|
|
19
|
+
# Merge islands mapping
|
|
20
|
+
map["imports"].merge!(islands_mapping)
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
# Merge additional map
|
|
23
|
+
if additional_map["imports"]
|
|
24
|
+
map["imports"].merge!(additional_map["imports"])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Merge other keys
|
|
28
|
+
additional_map.each do |k, v|
|
|
29
|
+
next if k == "imports"
|
|
30
|
+
map[k] = v
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
map
|
|
22
34
|
end
|
|
23
|
-
end
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
def load
|
|
37
|
+
path = find_deno_json
|
|
38
|
+
return {} unless path
|
|
28
39
|
|
|
29
|
-
|
|
40
|
+
begin
|
|
41
|
+
content = File.read(path)
|
|
42
|
+
json = JSON.parse(content)
|
|
43
|
+
json["imports"] || {}
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
49
|
+
def keys
|
|
50
|
+
load.keys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def default_imports
|
|
56
|
+
{
|
|
57
|
+
"preact" => "https://esm.sh/preact@10.19.6",
|
|
58
|
+
"preact/hooks" => "https://esm.sh/preact@10.19.6/hooks",
|
|
59
|
+
"preact/jsx-runtime" => "https://esm.sh/preact@10.19.6/jsx-runtime",
|
|
60
|
+
"@hotwired/turbo" => "https://esm.sh/@hotwired/turbo@8.0.0"
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load_deno_imports
|
|
65
|
+
imports = load
|
|
66
|
+
return nil if imports.empty?
|
|
67
|
+
|
|
68
|
+
# Convert npm: scheme to https://esm.sh/
|
|
69
|
+
imports.transform_values do |v|
|
|
70
|
+
if v.is_a?(String) && v.start_with?("npm:")
|
|
71
|
+
package = v.sub("npm:", "")
|
|
72
|
+
"https://esm.sh/#{package}"
|
|
73
|
+
else
|
|
74
|
+
v
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def islands_mapping
|
|
80
|
+
mapping = {}
|
|
81
|
+
islands_path = Salvia.development? ? "/salvia/assets/islands/" : "/assets/islands/"
|
|
82
|
+
|
|
83
|
+
# Base mapping for directory
|
|
84
|
+
mapping["@/islands/"] = islands_path
|
|
85
|
+
|
|
86
|
+
if Salvia.development?
|
|
87
|
+
# Development: Scan islands directory and map each component
|
|
88
|
+
# This ensures we can import "Counter" and get "Counter.tsx" (or whatever DevServer serves)
|
|
89
|
+
# Note: DevServer should handle extension resolution or we map to specific files here.
|
|
90
|
+
# Assuming DevServer handles extensionless requests or we map to .js if compiled on the fly.
|
|
91
|
+
# For now, we map to the file path relative to islands_path.
|
|
92
|
+
# If we use Sidecar/DevServer, it likely serves compiled JS.
|
|
93
|
+
|
|
94
|
+
# Scan app/islands
|
|
95
|
+
islands_dir = Salvia.config.islands_dir
|
|
96
|
+
if File.directory?(islands_dir)
|
|
97
|
+
Dir.glob("#{islands_dir}/**/*.{tsx,jsx,js}").each do |file|
|
|
98
|
+
next if File.basename(file).start_with?("_")
|
|
99
|
+
|
|
100
|
+
name = File.basename(file, ".*")
|
|
101
|
+
# Map "Counter" -> "/salvia/assets/islands/Counter.tsx" (or let DevServer handle it)
|
|
102
|
+
# If we map to extensionless, browser requests extensionless.
|
|
103
|
+
# Let's assume DevServer handles it.
|
|
104
|
+
# But to be safe and consistent with prod, we might want to map to a specific URL.
|
|
105
|
+
|
|
106
|
+
# For simplicity in dev, we can just rely on @/islands/ prefix if imports use extensions.
|
|
107
|
+
# But if imports are "Counter", we need a map.
|
|
108
|
+
mapping["@/islands/#{name}"] = File.join(islands_path, File.basename(file))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
# Production: Use manifest
|
|
113
|
+
manifest_path = File.join(Salvia.root, "salvia/server/manifest.json")
|
|
114
|
+
if File.exist?(manifest_path)
|
|
115
|
+
begin
|
|
116
|
+
manifest = JSON.parse(File.read(manifest_path))
|
|
117
|
+
manifest.each do |name, info|
|
|
118
|
+
if info["file"]
|
|
119
|
+
mapping["@/islands/#{name}"] = File.join(islands_path, info["file"])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
# Ignore error
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
mapping
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find_deno_json
|
|
132
|
+
Salvia.config.deno_config_path
|
|
133
|
+
end
|
|
34
134
|
end
|
|
35
135
|
end
|
|
36
136
|
end
|
|
@@ -40,57 +40,11 @@ module Salvia
|
|
|
40
40
|
|
|
41
41
|
# Import Map タグを生成する
|
|
42
42
|
def salvia_import_map(additional_map = {})
|
|
43
|
-
|
|
44
|
-
"imports" => {
|
|
45
|
-
"preact" => "https://esm.sh/preact@10.19.6",
|
|
46
|
-
"preact/hooks" => "https://esm.sh/preact@10.19.6/hooks",
|
|
47
|
-
"preact/jsx-runtime" => "https://esm.sh/preact@10.19.6/jsx-runtime",
|
|
48
|
-
"@hotwired/turbo" => "https://esm.sh/@hotwired/turbo@8.0.0"
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
# Islands path mapping
|
|
53
|
-
islands_path = if defined?(Salvia.env) && Salvia.env == "development"
|
|
54
|
-
"/salvia/assets/islands/"
|
|
55
|
-
else
|
|
56
|
-
"/assets/islands/"
|
|
57
|
-
end
|
|
58
|
-
default_map["imports"]["@/islands/"] = islands_path
|
|
59
|
-
|
|
60
|
-
# deno.json から imports を読み込む
|
|
61
|
-
begin
|
|
62
|
-
deno_json_path = File.join(Salvia.root, "salvia/deno.json")
|
|
63
|
-
if File.exist?(deno_json_path)
|
|
64
|
-
deno_config = JSON.parse(File.read(deno_json_path))
|
|
65
|
-
if deno_config["imports"]
|
|
66
|
-
# npm: スキームを https://esm.sh/ に変換してブラウザで使えるようにする
|
|
67
|
-
imports = deno_config["imports"].transform_values do |v|
|
|
68
|
-
if v.is_a?(String) && v.start_with?("npm:")
|
|
69
|
-
package = v.sub("npm:", "")
|
|
70
|
-
"https://esm.sh/#{package}"
|
|
71
|
-
else
|
|
72
|
-
v
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
default_map["imports"].merge!(imports)
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
rescue => e
|
|
79
|
-
# 読み込みエラー時は無視
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
if additional_map.key?("imports")
|
|
83
|
-
default_map["imports"].merge!(additional_map["imports"])
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
additional_map.each do |k, v|
|
|
87
|
-
next if k == "imports"
|
|
88
|
-
default_map[k] = v
|
|
89
|
-
end
|
|
43
|
+
map = Salvia::Core::ImportMap.generate(additional_map)
|
|
90
44
|
|
|
91
45
|
html = <<~HTML
|
|
92
46
|
<script type="importmap">
|
|
93
|
-
#{
|
|
47
|
+
#{map.to_json}
|
|
94
48
|
</script>
|
|
95
49
|
HTML
|
|
96
50
|
|
|
@@ -120,7 +74,9 @@ module Salvia
|
|
|
120
74
|
# @example ハイドレーションを無効化 (静的 HTML のみ)
|
|
121
75
|
# <%= island "StaticCard", title: "Hello", hydrate: false %>
|
|
122
76
|
#
|
|
77
|
+
# @deprecated Use Salvia::SSR.render in controller instead.
|
|
123
78
|
def island(name, props = {}, options = {})
|
|
79
|
+
warn "[DEPRECATION] `island` helper is deprecated. Please use `Salvia::SSR.render` in your controller (API Mode) instead."
|
|
124
80
|
tag_name = options.delete(:tag) || :div
|
|
125
81
|
|
|
126
82
|
# デフォルトの hydrate 値を決定
|
|
@@ -190,18 +146,11 @@ module Salvia
|
|
|
190
146
|
#
|
|
191
147
|
# @param name [String] ページコンポーネント名 (例: "pages/Home")
|
|
192
148
|
# @param props [Hash] プロパティ
|
|
149
|
+
# @param options [Hash] オプション
|
|
150
|
+
# @option options [Boolean] :doctype <!DOCTYPE html> を付与するか (デフォルト: true)
|
|
193
151
|
# @return [String] 完全な HTML 文字列
|
|
194
|
-
def ssr(name, props = {})
|
|
195
|
-
|
|
196
|
-
html = Salvia::SSR.render(name, props)
|
|
197
|
-
|
|
198
|
-
# <head> がある場合、Import Map を自動注入
|
|
199
|
-
if html.include?("</head>")
|
|
200
|
-
import_map_html = salvia_import_map
|
|
201
|
-
html = html.sub("</head>", "#{import_map_html}</head>")
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
result = "<!DOCTYPE html>\n" + html
|
|
152
|
+
def ssr(name, props = {}, options = {})
|
|
153
|
+
result = Salvia::SSR.render_page(name, props, options)
|
|
205
154
|
result.respond_to?(:html_safe) ? result.html_safe : result
|
|
206
155
|
end
|
|
207
156
|
|
|
@@ -7,14 +7,23 @@ module Salvia
|
|
|
7
7
|
|
|
8
8
|
def call(env)
|
|
9
9
|
# Only active in development
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Prefer Rails.env if available, otherwise check ENV
|
|
11
|
+
is_dev = if defined?(Rails) && Rails.respond_to?(:env)
|
|
12
|
+
Rails.env.development?
|
|
13
|
+
else
|
|
14
|
+
ENV["RACK_ENV"] == "development" || ENV["RAILS_ENV"] == "development"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
return @app.call(env) unless is_dev
|
|
13
18
|
|
|
14
19
|
request = Rack::Request.new(env)
|
|
15
20
|
|
|
16
21
|
if request.path.start_with?("/salvia/assets/")
|
|
17
22
|
handle_asset_request(request)
|
|
23
|
+
elsif request.path == "/assets/javascripts/islands.js"
|
|
24
|
+
# Handle islands.js specifically for development convenience
|
|
25
|
+
# This matches the path used in Home.tsx template and ensures it works without build
|
|
26
|
+
serve_islands_js
|
|
18
27
|
else
|
|
19
28
|
@app.call(env)
|
|
20
29
|
end
|
|
@@ -31,8 +40,9 @@ module Salvia
|
|
|
31
40
|
return serve_islands_js
|
|
32
41
|
end
|
|
33
42
|
|
|
34
|
-
# Remove
|
|
35
|
-
|
|
43
|
+
# Remove extension to find source (supports .js, .tsx, .ts, .jsx)
|
|
44
|
+
# Also handle requests without extension (from import map resolution)
|
|
45
|
+
base_name = path_info.sub(/\.(js|tsx|ts|jsx)$/, "")
|
|
36
46
|
|
|
37
47
|
source_path = resolve_source_path(base_name)
|
|
38
48
|
|
|
@@ -44,8 +54,6 @@ module Salvia
|
|
|
44
54
|
# Bundle for browser (ESM)
|
|
45
55
|
# We externalize dependencies that should be handled by Import Map
|
|
46
56
|
externals = Salvia::Core::ImportMap.new.keys
|
|
47
|
-
# Always externalize framework aliases just in case
|
|
48
|
-
externals += ["framework", "framework/hooks", "framework/jsx-runtime"]
|
|
49
57
|
|
|
50
58
|
js_code = Salvia::Compiler.bundle(
|
|
51
59
|
source_path,
|
|
@@ -64,13 +72,19 @@ module Salvia
|
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def serve_islands_js
|
|
67
|
-
# Check user
|
|
75
|
+
# 1. Check public assets (Build output or user override)
|
|
76
|
+
public_path = File.join(Salvia.root, "public/assets/javascripts/islands.js")
|
|
77
|
+
if File.exist?(public_path)
|
|
78
|
+
return [200, { "content-type" => "application/javascript" }, [File.read(public_path)]]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 2. Check user's source (salvia/assets/javascripts/islands.js) - Legacy/Custom path
|
|
68
82
|
user_path = File.join(Salvia.root, "salvia/assets/javascripts/islands.js")
|
|
69
83
|
if File.exist?(user_path)
|
|
70
84
|
return [200, { "content-type" => "application/javascript" }, [File.read(user_path)]]
|
|
71
85
|
end
|
|
72
86
|
|
|
73
|
-
# Fallback to internal islands.js
|
|
87
|
+
# 3. Fallback to internal islands.js
|
|
74
88
|
internal_path = File.expand_path("../../../assets/javascripts/islands.js", __dir__)
|
|
75
89
|
if File.exist?(internal_path)
|
|
76
90
|
return [200, { "content-type" => "application/javascript" }, [File.read(internal_path)]]
|
|
@@ -6,7 +6,7 @@ require 'timeout'
|
|
|
6
6
|
module Salvia
|
|
7
7
|
module Server
|
|
8
8
|
class Sidecar
|
|
9
|
-
SCRIPT_PATH = File.
|
|
9
|
+
SCRIPT_PATH = File.expand_path("../../../assets/scripts/sidecar.ts", __dir__)
|
|
10
10
|
|
|
11
11
|
def self.instance
|
|
12
12
|
@instance ||= new
|
|
@@ -15,45 +15,70 @@ module Salvia
|
|
|
15
15
|
def initialize
|
|
16
16
|
@pid = nil
|
|
17
17
|
@port = nil
|
|
18
|
+
@mutex = Mutex.new
|
|
18
19
|
at_exit { stop }
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def start
|
|
22
|
-
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
return if running?
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
26
|
+
cmd = ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "--allow-run", SCRIPT_PATH]
|
|
27
|
+
|
|
28
|
+
puts "🚀 Starting Salvia Sidecar..."
|
|
29
|
+
# Spawn process and capture stdout to find the port
|
|
30
|
+
# We use IO.popen to read the output stream
|
|
31
|
+
@io = IO.popen(cmd, err: [:child, :out])
|
|
32
|
+
@pid = @io.pid
|
|
33
|
+
|
|
34
|
+
# Wait for JSON handshake
|
|
35
|
+
wait_for_handshake
|
|
36
|
+
|
|
37
|
+
# Detach so it runs in background, but we keep the IO open to read logs if needed
|
|
38
|
+
# Actually, for IO.popen, we shouldn't detach if we want to read from it.
|
|
39
|
+
# But we need to read in a non-blocking way or in a separate thread after finding the port.
|
|
40
|
+
|
|
41
|
+
Thread.new do
|
|
42
|
+
begin
|
|
43
|
+
while line = @io.gets
|
|
44
|
+
# Forward Deno logs to stdout/logger
|
|
45
|
+
puts "[Deno] #{line}"
|
|
46
|
+
end
|
|
47
|
+
rescue IOError
|
|
48
|
+
# Stream closed
|
|
44
49
|
end
|
|
45
|
-
rescue IOError
|
|
46
|
-
# Stream closed
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def stop
|
|
52
55
|
return unless @pid
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
Process.kill("TERM", @pid)
|
|
59
|
+
|
|
60
|
+
# Wait up to 5 seconds for graceful shutdown
|
|
61
|
+
50.times do
|
|
62
|
+
pid = Process.waitpid(@pid, Process::WNOHANG)
|
|
63
|
+
if pid
|
|
64
|
+
@pid = nil
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
sleep 0.1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Force kill if still running
|
|
71
|
+
if @pid
|
|
72
|
+
Process.kill("KILL", @pid)
|
|
73
|
+
Process.waitpid(@pid, Process::WNOHANG) rescue nil
|
|
74
|
+
end
|
|
75
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
76
|
+
# Process already dead
|
|
77
|
+
ensure
|
|
78
|
+
@pid = nil
|
|
79
|
+
@port = nil
|
|
80
|
+
@io.close if @io && !@io.closed?
|
|
81
|
+
end
|
|
57
82
|
end
|
|
58
83
|
|
|
59
84
|
def running?
|
|
@@ -97,16 +122,28 @@ module Salvia
|
|
|
97
122
|
|
|
98
123
|
private
|
|
99
124
|
|
|
100
|
-
def
|
|
101
|
-
Timeout.timeout(
|
|
125
|
+
def wait_for_handshake
|
|
126
|
+
Timeout.timeout(30) do
|
|
102
127
|
while line = @io.gets
|
|
103
|
-
|
|
104
|
-
if
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
# Try to parse JSON handshake
|
|
129
|
+
if line.strip.start_with?("{") && line.include?("port")
|
|
130
|
+
begin
|
|
131
|
+
data = JSON.parse(line)
|
|
132
|
+
if data["port"]
|
|
133
|
+
@port = data["port"]
|
|
134
|
+
puts "✅ Salvia Sidecar connected on port #{@port}"
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
# Not JSON, just log
|
|
139
|
+
puts "[Deno Init] #{line}"
|
|
140
|
+
end
|
|
141
|
+
else
|
|
142
|
+
puts "[Deno Init] #{line}"
|
|
108
143
|
end
|
|
109
144
|
end
|
|
145
|
+
# If we exit the loop, it means EOF (process died)
|
|
146
|
+
raise "Deno Sidecar crashed unexpectedly. Check logs."
|
|
110
147
|
end
|
|
111
148
|
rescue Timeout::Error
|
|
112
149
|
stop
|
data/lib/salvia/ssr/dom_mock.rb
CHANGED
|
@@ -12,12 +12,20 @@ module Salvia
|
|
|
12
12
|
globalThis.addEventListener = function() {};
|
|
13
13
|
globalThis.removeEventListener = function() {};
|
|
14
14
|
globalThis.document = {
|
|
15
|
-
createElement: function() {
|
|
15
|
+
createElement: function(tag) {
|
|
16
|
+
return {
|
|
17
|
+
tagName: tag ? tag.toUpperCase() : 'DIV',
|
|
18
|
+
setAttribute: function() {},
|
|
19
|
+
appendChild: function() {},
|
|
20
|
+
style: {}
|
|
21
|
+
};
|
|
22
|
+
},
|
|
16
23
|
createTextNode: function() { return {}; },
|
|
17
24
|
addEventListener: function() { },
|
|
18
25
|
removeEventListener: function() { },
|
|
19
|
-
head: {},
|
|
20
|
-
body: {},
|
|
26
|
+
head: { appendChild: function() {} },
|
|
27
|
+
body: { appendChild: function() {} },
|
|
28
|
+
getElementById: function() { return null; },
|
|
21
29
|
documentElement: {
|
|
22
30
|
addEventListener: function() { },
|
|
23
31
|
removeEventListener: function() { }
|