salvia 0.2.0 → 0.2.1
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 +10 -50
- data/lib/salvia/server/dev_server.rb +22 -8
- 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 +41 -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
|
|
|
@@ -190,8 +144,10 @@ module Salvia
|
|
|
190
144
|
#
|
|
191
145
|
# @param name [String] ページコンポーネント名 (例: "pages/Home")
|
|
192
146
|
# @param props [Hash] プロパティ
|
|
147
|
+
# @param options [Hash] オプション
|
|
148
|
+
# @option options [Boolean] :doctype <!DOCTYPE html> を付与するか (デフォルト: true)
|
|
193
149
|
# @return [String] 完全な HTML 文字列
|
|
194
|
-
def ssr(name, props = {})
|
|
150
|
+
def ssr(name, props = {}, options = {})
|
|
195
151
|
# SSR で HTML を生成
|
|
196
152
|
html = Salvia::SSR.render(name, props)
|
|
197
153
|
|
|
@@ -201,7 +157,11 @@ module Salvia
|
|
|
201
157
|
html = html.sub("</head>", "#{import_map_html}</head>")
|
|
202
158
|
end
|
|
203
159
|
|
|
204
|
-
result =
|
|
160
|
+
result = html
|
|
161
|
+
if options.fetch(:doctype, true)
|
|
162
|
+
result = "<!DOCTYPE html>\n" + result
|
|
163
|
+
end
|
|
164
|
+
|
|
205
165
|
result.respond_to?(:html_safe) ? result.html_safe : result
|
|
206
166
|
end
|
|
207
167
|
|
|
@@ -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,7 +40,8 @@ module Salvia
|
|
|
31
40
|
return serve_islands_js
|
|
32
41
|
end
|
|
33
42
|
|
|
34
|
-
# Remove .js extension to find source
|
|
43
|
+
# Remove .js extension to find source (if present)
|
|
44
|
+
# Also handle requests without extension (from import map resolution)
|
|
35
45
|
base_name = path_info.sub(/\.js$/, "")
|
|
36
46
|
|
|
37
47
|
source_path = resolve_source_path(base_name)
|
|
@@ -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() { }
|
data/lib/salvia/ssr/quickjs.rb
CHANGED
|
@@ -15,21 +15,13 @@ module Salvia
|
|
|
15
15
|
@last_build_error = nil
|
|
16
16
|
@development = options.fetch(:development, true)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
load_console_shim!
|
|
21
|
-
|
|
22
|
-
if @development
|
|
23
|
-
load_vendor_bundle!
|
|
24
|
-
else
|
|
25
|
-
load_ssr_bundle!
|
|
26
|
-
end
|
|
18
|
+
# VM initialization is deferred to thread-local access
|
|
27
19
|
|
|
28
20
|
mark_initialized!
|
|
29
21
|
end
|
|
30
22
|
|
|
31
23
|
def shutdown!
|
|
32
|
-
|
|
24
|
+
Thread.current[:salvia_quickjs_vm] = nil
|
|
33
25
|
@js_logs = []
|
|
34
26
|
@initialized = false
|
|
35
27
|
Salvia::Compiler.shutdown if @development
|
|
@@ -49,7 +41,24 @@ module Salvia
|
|
|
49
41
|
private
|
|
50
42
|
|
|
51
43
|
def vm
|
|
52
|
-
|
|
44
|
+
Thread.current[:salvia_quickjs_vm] ||= create_thread_local_vm
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset_vm!
|
|
48
|
+
Thread.current[:salvia_quickjs_vm] = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_thread_local_vm
|
|
52
|
+
new_vm = ::Quickjs::VM.new
|
|
53
|
+
load_console_shim!(new_vm)
|
|
54
|
+
|
|
55
|
+
if @development
|
|
56
|
+
load_vendor_bundle!(new_vm)
|
|
57
|
+
else
|
|
58
|
+
load_ssr_bundle!(new_vm)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
new_vm
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
def render_jit(component_name, props)
|
|
@@ -59,23 +68,57 @@ module Salvia
|
|
|
59
68
|
raise Error, "Component not found: #{component_name}"
|
|
60
69
|
end
|
|
61
70
|
|
|
62
|
-
# Bundle component
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
externals: ["preact", "preact/hooks", "preact-render-to-string"],
|
|
66
|
-
format: "iife",
|
|
67
|
-
global_name: "SalviaComponent"
|
|
68
|
-
)
|
|
71
|
+
# Bundle component (with simple memory cache)
|
|
72
|
+
@bundle_cache ||= {}
|
|
73
|
+
mtime = File.mtime(path)
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
if @bundle_cache[path].nil? || @bundle_cache[path][:mtime] != mtime
|
|
76
|
+
# Load externals from deno.json if possible, otherwise use defaults
|
|
77
|
+
externals = ["preact", "preact/hooks", "preact/jsx-runtime", "preact-render-to-string", "@preact/signals"]
|
|
78
|
+
|
|
72
79
|
begin
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
deno_json_path = File.join(Salvia.root, "salvia/deno.json")
|
|
81
|
+
if File.exist?(deno_json_path)
|
|
82
|
+
deno_config = JSON.parse(File.read(deno_json_path))
|
|
83
|
+
if deno_config["imports"]
|
|
84
|
+
externals = (externals + deno_config["imports"].keys).uniq
|
|
85
|
+
end
|
|
76
86
|
end
|
|
77
87
|
rescue => e
|
|
78
|
-
|
|
88
|
+
log_warn("Failed to load externals from deno.json: #{e.message}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
js_code = Salvia::Compiler.bundle(
|
|
92
|
+
path,
|
|
93
|
+
externals: externals,
|
|
94
|
+
format: "iife",
|
|
95
|
+
global_name: "SalviaComponent"
|
|
96
|
+
)
|
|
97
|
+
@bundle_cache[path] = { code: js_code, mtime: mtime }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
js_code = @bundle_cache[path][:code]
|
|
101
|
+
|
|
102
|
+
# Async Type Check (Debounced)
|
|
103
|
+
@last_check_time ||= {}
|
|
104
|
+
now = Time.now.to_i
|
|
105
|
+
|
|
106
|
+
# Debounce: Check at most once every 5 seconds per file
|
|
107
|
+
if @last_check_time[path].nil? || (now - @last_check_time[path]) > 5
|
|
108
|
+
@last_check_time[path] = now
|
|
109
|
+
|
|
110
|
+
# Use a background thread but avoid spawning too many
|
|
111
|
+
# Ideally this should be a single worker queue, but for now simple detach is better than nothing
|
|
112
|
+
Thread.new do
|
|
113
|
+
begin
|
|
114
|
+
# Double check inside thread to handle race conditions slightly better
|
|
115
|
+
result = Salvia::Compiler.check(path)
|
|
116
|
+
unless result["success"]
|
|
117
|
+
log_warn("Type Check Failed for #{component_name}:\n#{result["message"]}")
|
|
118
|
+
end
|
|
119
|
+
rescue => e
|
|
120
|
+
log_debug("Type Check Error: #{e.message}")
|
|
121
|
+
end
|
|
79
122
|
end
|
|
80
123
|
end
|
|
81
124
|
|
|
@@ -148,7 +191,7 @@ module Salvia
|
|
|
148
191
|
end
|
|
149
192
|
|
|
150
193
|
def eval_js(code)
|
|
151
|
-
result =
|
|
194
|
+
result = vm.eval_code(code)
|
|
152
195
|
process_console_output
|
|
153
196
|
result
|
|
154
197
|
rescue => e
|
|
@@ -156,21 +199,24 @@ module Salvia
|
|
|
156
199
|
raise e
|
|
157
200
|
end
|
|
158
201
|
|
|
159
|
-
def load_console_shim!
|
|
202
|
+
def load_console_shim!(target_vm)
|
|
160
203
|
shim = generate_console_shim
|
|
161
|
-
|
|
162
|
-
|
|
204
|
+
target_vm.eval_code(shim)
|
|
205
|
+
target_vm.eval_code(Salvia::SSR::DomMock.generate_shim)
|
|
163
206
|
rescue => e
|
|
164
207
|
log_error("Failed to load console shim: #{e.message}")
|
|
165
208
|
end
|
|
166
209
|
|
|
167
|
-
def load_vendor_bundle!
|
|
210
|
+
def load_vendor_bundle!(target_vm)
|
|
168
211
|
# Use internal vendor_setup.ts for Zero Config
|
|
169
212
|
vendor_path = File.expand_path("../../../assets/scripts/vendor_setup.ts", __dir__)
|
|
170
213
|
|
|
171
214
|
if File.exist?(vendor_path)
|
|
215
|
+
# Ensure module.exports shim exists before loading vendor bundle
|
|
216
|
+
target_vm.eval_code("if(typeof module === 'undefined') { globalThis.module = { exports: {} }; }")
|
|
217
|
+
|
|
172
218
|
code = Salvia::Compiler.bundle(vendor_path, format: "iife")
|
|
173
|
-
|
|
219
|
+
target_vm.eval_code(code)
|
|
174
220
|
log_info("Loaded Vendor bundle (Internal)")
|
|
175
221
|
else
|
|
176
222
|
log_error("Internal vendor_setup.ts not found at #{vendor_path}")
|
|
@@ -179,15 +225,27 @@ module Salvia
|
|
|
179
225
|
log_error("Failed to load vendor bundle: #{e.message}")
|
|
180
226
|
end
|
|
181
227
|
|
|
182
|
-
def load_ssr_bundle!
|
|
228
|
+
def load_ssr_bundle!(target_vm)
|
|
183
229
|
bundle_path = options[:bundle_path] || default_bundle_path
|
|
184
230
|
|
|
185
231
|
unless File.exist?(bundle_path)
|
|
186
232
|
raise Error, "SSR bundle not found: #{bundle_path}"
|
|
187
233
|
end
|
|
188
234
|
|
|
235
|
+
# Check if bundle has changed (simple reload strategy)
|
|
236
|
+
mtime = File.mtime(bundle_path)
|
|
237
|
+
if @last_bundle_mtime && @last_bundle_mtime != mtime
|
|
238
|
+
log_info("SSR bundle changed, reloading...")
|
|
239
|
+
# Note: In a real scenario, we might need to reset the VM completely
|
|
240
|
+
# But since this is called inside create_thread_local_vm, we are creating a new VM anyway.
|
|
241
|
+
# However, if we want to support hot reload in production without restarting threads,
|
|
242
|
+
# we would need a mechanism to invalidate all thread-local VMs.
|
|
243
|
+
# For now, we just update the mtime tracking.
|
|
244
|
+
end
|
|
245
|
+
@last_bundle_mtime = mtime
|
|
246
|
+
|
|
189
247
|
bundle_content = File.read(bundle_path)
|
|
190
|
-
|
|
248
|
+
target_vm.eval_code(bundle_content)
|
|
191
249
|
log_info("Loaded SSR bundle: #{bundle_path}")
|
|
192
250
|
end
|
|
193
251
|
|
|
@@ -196,7 +254,7 @@ module Salvia
|
|
|
196
254
|
end
|
|
197
255
|
|
|
198
256
|
def process_console_output
|
|
199
|
-
logs_json =
|
|
257
|
+
logs_json = vm.eval_code("globalThis.__salvia_flush_logs__()")
|
|
200
258
|
return if logs_json.nil? || logs_json.empty?
|
|
201
259
|
|
|
202
260
|
begin
|
|
@@ -270,7 +328,12 @@ module Salvia
|
|
|
270
328
|
end
|
|
271
329
|
|
|
272
330
|
def escape_html(str)
|
|
273
|
-
str.to_s
|
|
331
|
+
str.to_s
|
|
332
|
+
.gsub("&", "&")
|
|
333
|
+
.gsub("<", "<")
|
|
334
|
+
.gsub(">", ">")
|
|
335
|
+
.gsub('"', """)
|
|
336
|
+
.gsub("'", "'")
|
|
274
337
|
end
|
|
275
338
|
|
|
276
339
|
def log_info(msg); defined?(Salvia.logger) ? Salvia.logger.info(msg) : puts("[SSR] #{msg}"); end
|