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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +152 -0
- data/assets/components/Island.tsx +15 -0
- data/assets/islands/Counter.tsx +17 -0
- data/assets/javascripts/islands.js +55 -0
- data/assets/pages/Home.tsx +19 -0
- data/assets/scripts/build.ts +298 -0
- data/assets/scripts/deno.json +15 -0
- data/assets/scripts/deno.lock +56 -0
- data/assets/scripts/sidecar.ts +167 -0
- data/assets/scripts/vendor_setup.ts +25 -0
- data/exe/salvia +8 -0
- data/lib/salvia/cli.rb +189 -0
- data/lib/salvia/compiler/adapters/deno_sidecar.rb +23 -0
- data/lib/salvia/compiler.rb +29 -0
- data/lib/salvia/core/configuration.rb +30 -0
- data/lib/salvia/core/error.rb +7 -0
- data/lib/salvia/core/import_map.rb +36 -0
- data/lib/salvia/core/path_resolver.rb +43 -0
- data/lib/salvia/helpers/island.rb +251 -0
- data/lib/salvia/helpers/tag.rb +47 -0
- data/lib/salvia/helpers.rb +8 -0
- data/lib/salvia/railtie.rb +33 -0
- data/lib/salvia/server/dev_server.rb +83 -0
- data/lib/salvia/server/sidecar.rb +136 -0
- data/lib/salvia/server/sidecar.ts +167 -0
- data/lib/salvia/ssr/dom_mock.rb +46 -0
- data/lib/salvia/ssr/quickjs.rb +282 -0
- data/lib/salvia/ssr.rb +119 -0
- data/lib/salvia/version.rb +5 -0
- data/lib/salvia.rb +68 -0
- metadata +165 -0
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
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
|
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)
|