trmnl_preview 0.7.1 → 0.8.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 +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +180 -5
- data/bin/rake +6 -6
- data/bin/trmnlp +1 -0
- data/db/data/form_fields.yml +24 -0
- data/db/data/framework_versions.yml +72 -0
- data/lib/trmnlp/api_client.rb +41 -28
- data/lib/trmnlp/app.rb +73 -44
- data/lib/trmnlp/browser_pool.rb +82 -0
- data/lib/trmnlp/cli.rb +24 -11
- data/lib/trmnlp/commands/base.rb +33 -10
- data/lib/trmnlp/commands/build.rb +13 -8
- data/lib/trmnlp/commands/clone.rb +12 -7
- data/lib/trmnlp/commands/init.rb +17 -13
- data/lib/trmnlp/commands/lint.rb +42 -0
- data/lib/trmnlp/commands/list.rb +40 -0
- data/lib/trmnlp/commands/login.rb +28 -13
- data/lib/trmnlp/commands/pull.rb +14 -6
- data/lib/trmnlp/commands/push.rb +29 -19
- data/lib/trmnlp/commands/serve.rb +32 -3
- data/lib/trmnlp/commands.rb +3 -1
- data/lib/trmnlp/config/app.rb +6 -3
- data/lib/trmnlp/config/plugin.rb +56 -14
- data/lib/trmnlp/config/project.rb +59 -7
- data/lib/trmnlp/config.rb +3 -1
- data/lib/trmnlp/context.rb +21 -224
- data/lib/trmnlp/errors.rb +15 -0
- data/lib/trmnlp/form_field.rb +42 -0
- data/lib/trmnlp/framework_version.rb +69 -0
- data/lib/trmnlp/image_quantizer.rb +58 -0
- data/lib/trmnlp/lint/check.rb +31 -0
- data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
- data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
- data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
- data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
- data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
- data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
- data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
- data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
- data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
- data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
- data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
- data/lib/trmnlp/lint/checks/title_length.rb +18 -0
- data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
- data/lib/trmnlp/lint/source.rb +42 -0
- data/lib/trmnlp/lint.rb +39 -0
- data/lib/trmnlp/paths.rb +28 -8
- data/lib/trmnlp/poller.rb +105 -0
- data/lib/trmnlp/renderer.rb +87 -0
- data/lib/trmnlp/reporter.rb +28 -0
- data/lib/trmnlp/screen.rb +16 -0
- data/lib/trmnlp/screen_generator.rb +11 -217
- data/lib/trmnlp/screenshot.rb +96 -0
- data/lib/trmnlp/transform_backend/http.rb +107 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
- data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
- data/lib/trmnlp/transform_client.rb +47 -0
- data/lib/trmnlp/transform_pipeline.rb +65 -0
- data/lib/trmnlp/user_data_assembler.rb +96 -0
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +60 -0
- data/lib/trmnlp.rb +6 -10
- data/templates/init/bin/trmnlp +1 -1
- data/templates/init/src/settings.yml +1 -0
- data/templates/init/src/transform.py.example +14 -0
- data/trmnl_preview.gemspec +34 -34
- data/web/public/index.css +6 -0
- data/web/public/index.js +31 -18
- data/web/views/index.erb +6 -1
- data/web/views/render_html.erb +4 -2
- metadata +81 -56
|
@@ -1,238 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require 'tempfile'
|
|
6
|
-
require 'fileutils'
|
|
7
|
-
require 'uri'
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'screenshot'
|
|
4
|
+
require_relative 'image_quantizer'
|
|
8
5
|
|
|
9
6
|
module TRMNLP
|
|
10
7
|
class ScreenGenerator
|
|
11
|
-
# Browser pool management for efficient resource usage
|
|
12
|
-
class BrowserPool
|
|
13
|
-
def initialize(max_size: 2)
|
|
14
|
-
@drivers = []
|
|
15
|
-
@available = Queue.new
|
|
16
|
-
@mutex = Mutex.new
|
|
17
|
-
@max_size = max_size
|
|
18
|
-
@shutdown = false
|
|
19
|
-
|
|
20
|
-
at_exit { shutdown }
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def with_driver
|
|
24
|
-
driver = nil
|
|
25
|
-
|
|
26
|
-
begin
|
|
27
|
-
driver = checkout_driver
|
|
28
|
-
yield driver
|
|
29
|
-
ensure
|
|
30
|
-
checkin_driver(driver) if driver
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def shutdown
|
|
35
|
-
@mutex.synchronize do
|
|
36
|
-
return if @shutdown
|
|
37
|
-
@shutdown = true
|
|
38
|
-
|
|
39
|
-
@drivers.each do |driver|
|
|
40
|
-
driver.quit rescue nil
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
@drivers.clear
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def checkout_driver
|
|
50
|
-
driver = @available.pop(true) rescue nil
|
|
51
|
-
|
|
52
|
-
if driver.nil?
|
|
53
|
-
@mutex.synchronize do
|
|
54
|
-
if @drivers.size < @max_size
|
|
55
|
-
driver = create_driver
|
|
56
|
-
@drivers << driver
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
driver ||= @available.pop
|
|
62
|
-
|
|
63
|
-
begin
|
|
64
|
-
# Ping the driver
|
|
65
|
-
driver.title
|
|
66
|
-
driver
|
|
67
|
-
rescue
|
|
68
|
-
@mutex.synchronize do
|
|
69
|
-
@drivers.delete(driver)
|
|
70
|
-
driver = create_driver
|
|
71
|
-
@drivers << driver
|
|
72
|
-
end
|
|
73
|
-
driver
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def checkin_driver(driver)
|
|
78
|
-
return if @shutdown
|
|
79
|
-
@available.push(driver)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def create_driver
|
|
83
|
-
options = Selenium::WebDriver::Firefox::Options.new
|
|
84
|
-
options.add_argument('--headless')
|
|
85
|
-
options.add_argument('--disable-web-security')
|
|
86
|
-
|
|
87
|
-
driver = Selenium::WebDriver.for(:firefox, options: options)
|
|
88
|
-
# Set a default window size that will be consistent
|
|
89
|
-
driver.manage.window.maximize
|
|
90
|
-
driver
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
@@browser_pool = BrowserPool.new
|
|
95
|
-
|
|
96
8
|
def initialize(html, opts = {})
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# Accept optional rendering parameters (width/height/color depth/dark mode)
|
|
9
|
+
@input = html
|
|
10
|
+
@screenshot = opts[:screenshot]
|
|
101
11
|
@requested_width = opts[:width]
|
|
102
12
|
@requested_height = opts[:height]
|
|
103
13
|
@requested_color_depth = opts[:color_depth]
|
|
104
14
|
end
|
|
105
15
|
|
|
106
|
-
attr_accessor :input, :output, :image
|
|
107
|
-
|
|
108
16
|
def process
|
|
109
|
-
|
|
110
|
-
|
|
17
|
+
output = @screenshot.call(html: @input, width:, height:)
|
|
18
|
+
ImageQuantizer.new(depth: color_depth).call(output.path)
|
|
111
19
|
output
|
|
112
20
|
end
|
|
113
21
|
|
|
114
22
|
private
|
|
115
23
|
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
begin
|
|
120
|
-
@@browser_pool.with_driver do |driver|
|
|
121
|
-
# determine dimensions of toolbars, etc
|
|
122
|
-
borders = driver.execute_script(<<~JS)
|
|
123
|
-
return {
|
|
124
|
-
width: window.outerWidth - window.innerWidth,
|
|
125
|
-
height: window.outerHeight - window.innerHeight
|
|
126
|
-
}
|
|
127
|
-
JS
|
|
128
|
-
|
|
129
|
-
window_width = width + borders['width']
|
|
130
|
-
window_height = height + borders['height']
|
|
131
|
-
driver.manage.window.size = Selenium::WebDriver::Dimension.new(window_width, window_height)
|
|
132
|
-
|
|
133
|
-
sleep(0.1)
|
|
134
|
-
|
|
135
|
-
prepare_page(driver)
|
|
136
|
-
|
|
137
|
-
self.output = Tempfile.new(['screenshot', '.png'])
|
|
138
|
-
driver.save_screenshot(output.path)
|
|
139
|
-
output.close
|
|
140
|
-
end
|
|
141
|
-
rescue Selenium::WebDriver::Error::TimeoutError,
|
|
142
|
-
Selenium::WebDriver::Error::WebDriverError => e
|
|
143
|
-
retry_count += 1
|
|
144
|
-
retry if retry_count <= 1
|
|
145
|
-
raise
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def prepare_page(driver)
|
|
150
|
-
driver.navigate.to('about:blank')
|
|
151
|
-
|
|
152
|
-
driver.execute_script(<<~JS, input)
|
|
153
|
-
document.open();
|
|
154
|
-
document.write(arguments[0]);
|
|
155
|
-
document.close();
|
|
156
|
-
JS
|
|
157
|
-
|
|
158
|
-
Selenium::WebDriver::Wait.new(timeout: 5).until do
|
|
159
|
-
driver.execute_script('return document.readyState') == 'complete'
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Wait for fonts (prevents layout shifts)
|
|
163
|
-
driver.execute_script('return document.fonts && document.fonts.ready')
|
|
164
|
-
|
|
165
|
-
driver.execute_script(<<~JS)
|
|
166
|
-
document.documentElement.style.overflow = 'hidden';
|
|
167
|
-
document.body.style.overflow = 'hidden';
|
|
168
|
-
JS
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def convert_with_mini_magick(img, depth)
|
|
172
|
-
tmp = Tempfile.new(['mono', '.png'])
|
|
173
|
-
tmp.close
|
|
174
|
-
|
|
175
|
-
levels = 2**depth
|
|
176
|
-
|
|
177
|
-
MiniMagick::Tool::Convert.new do |m|
|
|
178
|
-
m << img.path
|
|
179
|
-
m.colorspace 'Gray'
|
|
180
|
-
m.dither 'FloydSteinberg'
|
|
181
|
-
|
|
182
|
-
yield(m, depth, levels)
|
|
183
|
-
|
|
184
|
-
m.depth depth
|
|
185
|
-
m.define "png:bit-depth=#{depth}"
|
|
186
|
-
m.strip
|
|
187
|
-
m << tmp.path
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
FileUtils.mv(tmp.path, img.path, force: true)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def mono(img)
|
|
194
|
-
depth = [[color_depth.to_i, 1].max, 8].min
|
|
195
|
-
|
|
196
|
-
convert_with_mini_magick(img, depth) do |m, d, levels|
|
|
197
|
-
m.posterize levels
|
|
198
|
-
m.colors levels
|
|
199
|
-
m.type 'Bilevel' if d == 1
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def mono_image(img)
|
|
204
|
-
depth = [[color_depth.to_i, 1].max, 8].min
|
|
205
|
-
|
|
206
|
-
convert_with_mini_magick(img, depth) do |m, d, levels|
|
|
207
|
-
if d == 1
|
|
208
|
-
# For true 1-bit, use a halftone/remap and bilevel output
|
|
209
|
-
m.remap 'pattern:gray50'
|
|
210
|
-
m.posterize 2
|
|
211
|
-
m.colors 2
|
|
212
|
-
m.type 'Bilevel'
|
|
213
|
-
else
|
|
214
|
-
m.posterize levels
|
|
215
|
-
m.colors levels
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def width
|
|
222
|
-
@requested_width || 800
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def height
|
|
226
|
-
@requested_height || 480
|
|
227
|
-
end
|
|
24
|
+
def width = @requested_width || 800
|
|
25
|
+
def height = @requested_height || 480
|
|
228
26
|
|
|
229
27
|
def color_depth
|
|
230
28
|
return @requested_color_depth if @requested_color_depth
|
|
231
|
-
|
|
232
|
-
# Try to infer color depth from the rendered HTML's screen classes
|
|
233
|
-
if input && input.match(/screen--(\d+)bit/)
|
|
234
|
-
return $1.to_i
|
|
235
|
-
end
|
|
29
|
+
return ::Regexp.last_match(1).to_i if @input&.match(/screen--(\d+)bit/)
|
|
236
30
|
|
|
237
31
|
1
|
|
238
32
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module TRMNLP
|
|
7
|
+
class Screenshot
|
|
8
|
+
def initialize(pool:)
|
|
9
|
+
@pool = pool
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(html:, width:, height:)
|
|
13
|
+
attempts = 0
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
@pool.with_driver { |driver| render(driver, html, width, height) }
|
|
17
|
+
rescue Selenium::WebDriver::Error::TimeoutError,
|
|
18
|
+
Selenium::WebDriver::Error::WebDriverError
|
|
19
|
+
attempts += 1
|
|
20
|
+
retry if attempts <= 1
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def render(driver, html, width, height)
|
|
28
|
+
resize(driver, width, height)
|
|
29
|
+
load_page(driver, html)
|
|
30
|
+
capture(driver)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resize(driver, width, height)
|
|
34
|
+
apply_window_size(driver, width, height)
|
|
35
|
+
wait_for_viewport(driver, width, height)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def apply_window_size(driver, width, height)
|
|
39
|
+
borders = driver.execute_script(<<~JS)
|
|
40
|
+
return {
|
|
41
|
+
width: window.outerWidth - window.innerWidth,
|
|
42
|
+
height: window.outerHeight - window.innerHeight
|
|
43
|
+
}
|
|
44
|
+
JS
|
|
45
|
+
|
|
46
|
+
dim = Selenium::WebDriver::Dimension.new(width + borders['width'], height + borders['height'])
|
|
47
|
+
driver.manage.window.size = dim
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# NOTE: A cold Firefox — e.g. the first render after the container boots —
|
|
51
|
+
# applies a window resize lazily. The old fixed sleep raced that reflow and
|
|
52
|
+
# clipped the first screenshot short (800x433 instead of 800x480). Poll the
|
|
53
|
+
# real viewport instead, re-applying the size until it lands; a resize that
|
|
54
|
+
# never settles surfaces as a TimeoutError that #call already retries.
|
|
55
|
+
def wait_for_viewport(driver, width, height)
|
|
56
|
+
Selenium::WebDriver::Wait.new(timeout: 5, interval: 0.1).until do
|
|
57
|
+
next true if viewport(driver) == [width, height]
|
|
58
|
+
|
|
59
|
+
apply_window_size(driver, width, height)
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def viewport(driver)
|
|
65
|
+
driver.execute_script('return [window.innerWidth, window.innerHeight]')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load_page(driver, html)
|
|
69
|
+
driver.navigate.to('about:blank')
|
|
70
|
+
|
|
71
|
+
driver.execute_script(<<~JS, html)
|
|
72
|
+
document.open();
|
|
73
|
+
document.write(arguments[0]);
|
|
74
|
+
document.close();
|
|
75
|
+
JS
|
|
76
|
+
|
|
77
|
+
Selenium::WebDriver::Wait.new(timeout: 5).until do
|
|
78
|
+
driver.execute_script('return document.readyState') == 'complete'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
driver.execute_script('return document.fonts && document.fonts.ready')
|
|
82
|
+
|
|
83
|
+
driver.execute_script(<<~JS)
|
|
84
|
+
document.documentElement.style.overflow = 'hidden';
|
|
85
|
+
document.body.style.overflow = 'hidden';
|
|
86
|
+
JS
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def capture(driver)
|
|
90
|
+
file = Tempfile.new(['screenshot', '.png'])
|
|
91
|
+
driver.save_screenshot(file.path)
|
|
92
|
+
file.close
|
|
93
|
+
file
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative '../transform_client'
|
|
7
|
+
require_relative 'wrapper'
|
|
8
|
+
|
|
9
|
+
module TRMNLP
|
|
10
|
+
module TransformBackend
|
|
11
|
+
# Remote-daemon transform execution. Speaks the production daemon's
|
|
12
|
+
# wire format over HTTP so trmnlp can target a real remote transform
|
|
13
|
+
# daemon (or any compatible server) instead of running transforms
|
|
14
|
+
# locally. Selected by TransformClient.from_config when
|
|
15
|
+
# serverless_daemon_url is set in the project's .trmnlp.yml.
|
|
16
|
+
#
|
|
17
|
+
# The daemon expects code that already includes its own harness
|
|
18
|
+
# (reading stdin, dispatching to run/transform/result, writing the
|
|
19
|
+
# canonical JSON result to FD 3). The shared Wrapper module emits
|
|
20
|
+
# the same harness Subprocess uses, parameterized on a FD-3 sink
|
|
21
|
+
# so a transform behaves identically whether previewed against the
|
|
22
|
+
# daemon or run locally.
|
|
23
|
+
class Http
|
|
24
|
+
SUPPORTED_LANGUAGES = %w[python ruby php node].freeze
|
|
25
|
+
DEFAULT_TIMEOUT = 30
|
|
26
|
+
HTTP_TIMEOUT = 60
|
|
27
|
+
|
|
28
|
+
def initialize(url:, api_key: nil, http_timeout: HTTP_TIMEOUT)
|
|
29
|
+
@url = url
|
|
30
|
+
@api_key = api_key
|
|
31
|
+
@http_timeout = http_timeout
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT)
|
|
35
|
+
lang = language.to_s
|
|
36
|
+
return failure("unsupported serverless_language: #{lang}") unless SUPPORTED_LANGUAGES.include?(lang)
|
|
37
|
+
|
|
38
|
+
post(code: Wrapper.for(lang, code, sink_for(lang)), stdin:, timeout: timeout_seconds, language: lang)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def post(code:, stdin:, timeout:, language:)
|
|
44
|
+
response = connection.post('/execute') do |req|
|
|
45
|
+
req.body = JSON.generate(code:, stdin:, timeout:, language:)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return failure("daemon HTTP #{response.status}: #{response.body}") unless response.status == 200
|
|
49
|
+
|
|
50
|
+
parse(response.body)
|
|
51
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
52
|
+
failure("transform daemon unreachable at #{@url}: #{e.message}")
|
|
53
|
+
rescue JSON::ParserError => e
|
|
54
|
+
failure("daemon returned non-JSON response: #{e.message}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse(body)
|
|
58
|
+
parsed = JSON.parse(body)
|
|
59
|
+
TransformClient::Result.new(
|
|
60
|
+
stdout: parsed['stdout'] || '',
|
|
61
|
+
stderr: parsed['stderr'] || '',
|
|
62
|
+
# Fall back to stdout for daemons that haven't been upgraded to
|
|
63
|
+
# the separate `output` channel yet.
|
|
64
|
+
output: (parsed['output'].to_s.empty? ? parsed['stdout'] : parsed['output']).to_s,
|
|
65
|
+
exit_code: parsed['exit_code'] || 0,
|
|
66
|
+
duration_ms: parsed['duration_ms'] || 0,
|
|
67
|
+
error: parsed['error']
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# NOTE: the connection is memoized and never explicitly closed. That is
|
|
72
|
+
# safe only because Faraday's default adapter opens a fresh socket per
|
|
73
|
+
# request — swapping in a persistent adapter here would leak sockets.
|
|
74
|
+
def connection
|
|
75
|
+
@connection ||= Faraday.new(url: @url) do |f|
|
|
76
|
+
f.headers['Content-Type'] = 'application/json'
|
|
77
|
+
f.headers['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
78
|
+
f.options.timeout = @http_timeout
|
|
79
|
+
f.options.open_timeout = 5
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def failure(message)
|
|
84
|
+
TransformClient::Result.new(stdout: '', stderr: '', output: '', exit_code: -1, duration_ms: 0, error: message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Language-specific FD-3 sink snippets — what the daemon's
|
|
88
|
+
# harness reads from to capture canonical JSON output.
|
|
89
|
+
def sink_for(language)
|
|
90
|
+
case language
|
|
91
|
+
when 'python'
|
|
92
|
+
"os.write(3, json.dumps(output).encode('utf-8'))"
|
|
93
|
+
when 'ruby'
|
|
94
|
+
'IO.new(3).write(JSON.generate(output))'
|
|
95
|
+
when 'node'
|
|
96
|
+
<<~JS.chomp
|
|
97
|
+
Promise.resolve(output).then(o => {
|
|
98
|
+
require('fs').writeSync(3, JSON.stringify(o));
|
|
99
|
+
});
|
|
100
|
+
JS
|
|
101
|
+
when 'php'
|
|
102
|
+
"$fd = fopen('php://fd/3', 'w');\n fwrite($fd, json_encode($output));\n fclose($fd);"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
require_relative '../transform_client'
|
|
7
|
+
require_relative 'wrapper'
|
|
8
|
+
|
|
9
|
+
module TRMNLP
|
|
10
|
+
module TransformBackend
|
|
11
|
+
# Local subprocess execution of user transform code. Mirrors the
|
|
12
|
+
# remote-daemon wrapper contract so a transform behaves the same
|
|
13
|
+
# locally as it does in production. Output flows back via
|
|
14
|
+
# a tempfile per-execution instead of FD 3, but the
|
|
15
|
+
# run/result/input dispatch logic is preserved verbatim via the
|
|
16
|
+
# shared Wrapper module.
|
|
17
|
+
class Subprocess
|
|
18
|
+
DEFAULT_TIMEOUT = 30
|
|
19
|
+
# Seconds a TERM'd process is given to exit before escalating to KILL.
|
|
20
|
+
GRACE_PERIOD = 0.1
|
|
21
|
+
|
|
22
|
+
INTERPRETERS = {
|
|
23
|
+
'python' => { cmd: 'python3', ext: 'py' },
|
|
24
|
+
'ruby' => { cmd: 'ruby', ext: 'rb' },
|
|
25
|
+
'node' => { cmd: 'node', ext: 'js' },
|
|
26
|
+
'php' => { cmd: 'php', ext: 'php' }
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT)
|
|
30
|
+
spec = INTERPRETERS[language.to_s]
|
|
31
|
+
return failure("unsupported language: #{language}") unless spec
|
|
32
|
+
|
|
33
|
+
invoke(spec, language.to_s, code, stdin, timeout_seconds)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def invoke(spec, language, code, stdin, timeout_seconds)
|
|
39
|
+
Dir.mktmpdir('trmnlp-tx-') do |dir|
|
|
40
|
+
output_path = File.join(dir, 'output.json')
|
|
41
|
+
src_path = File.join(dir, "transform.#{spec[:ext]}")
|
|
42
|
+
File.write(src_path, Wrapper.for(language, code, sink_for(language, output_path)))
|
|
43
|
+
run_process(spec[:cmd], src_path, stdin, timeout_seconds, output_path)
|
|
44
|
+
end
|
|
45
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
46
|
+
failure("interpreter not available: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run_process(cmd, src_path, stdin, timeout_seconds, output_path)
|
|
50
|
+
started = monotonic_ms
|
|
51
|
+
|
|
52
|
+
Open3.popen3(cmd, src_path) do |stdin_io, stdout_io, stderr_io, wait_thr|
|
|
53
|
+
stdin_io.write(stdin)
|
|
54
|
+
stdin_io.close
|
|
55
|
+
|
|
56
|
+
unless wait_thr.join(timeout_seconds)
|
|
57
|
+
kill(wait_thr)
|
|
58
|
+
return failure("timeout after #{timeout_seconds}s", monotonic_ms - started)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
build_result(stdout_io.read, stderr_io.read, wait_thr.value, output_path, monotonic_ms - started)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_result(stdout, stderr, status, output_path, duration_ms)
|
|
66
|
+
TransformClient::Result.new(
|
|
67
|
+
stdout: stdout,
|
|
68
|
+
stderr: stderr,
|
|
69
|
+
output: read_output(output_path),
|
|
70
|
+
exit_code: status.exitstatus || -1,
|
|
71
|
+
duration_ms: duration_ms,
|
|
72
|
+
error: nil
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A transform that crashes before writing leaves no output file; a
|
|
77
|
+
# permissions/IO error on read is treated the same — empty output,
|
|
78
|
+
# which the pipeline surfaces as a non-JSON-output failure.
|
|
79
|
+
def read_output(path)
|
|
80
|
+
File.exist?(path) ? File.read(path) : ''
|
|
81
|
+
rescue SystemCallError
|
|
82
|
+
''
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# TERM first, escalating to KILL only if the process outlives the
|
|
86
|
+
# grace period. join returns the moment it exits, so a process that
|
|
87
|
+
# dies promptly on TERM costs near-zero wait, not a fixed sleep.
|
|
88
|
+
def kill(wait_thr)
|
|
89
|
+
Process.kill('TERM', wait_thr.pid)
|
|
90
|
+
return if wait_thr.join(GRACE_PERIOD)
|
|
91
|
+
|
|
92
|
+
Process.kill('KILL', wait_thr.pid)
|
|
93
|
+
rescue Errno::ESRCH
|
|
94
|
+
# already exited between TERM and KILL — fine
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def failure(message, duration_ms = 0)
|
|
98
|
+
TransformClient::Result.new(
|
|
99
|
+
stdout: '', stderr: '', output: '',
|
|
100
|
+
exit_code: -1, duration_ms: duration_ms, error: message
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def monotonic_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
105
|
+
|
|
106
|
+
# Language-specific tempfile sink snippets. The dispatch harness
|
|
107
|
+
# in Wrapper.* writes `output` (a serializable value); these
|
|
108
|
+
# snippets get it onto disk so the parent process can read it.
|
|
109
|
+
def sink_for(language, output_path)
|
|
110
|
+
case language
|
|
111
|
+
when 'python'
|
|
112
|
+
# A single statement — no block indentation to keep in sync with
|
|
113
|
+
# the Wrapper.python heredoc. open() is flushed and closed when
|
|
114
|
+
# CPython drops the temporary as the process exits.
|
|
115
|
+
"json.dump(output, open(#{output_path.inspect}, 'w'))"
|
|
116
|
+
when 'ruby'
|
|
117
|
+
"File.write(#{output_path.inspect}, JSON.generate(output))"
|
|
118
|
+
when 'node'
|
|
119
|
+
<<~JS.chomp
|
|
120
|
+
Promise.resolve(output)
|
|
121
|
+
.then((o) => require('fs').writeFileSync(#{output_path.inspect}, JSON.stringify(o)))
|
|
122
|
+
.catch((err) => { process.stderr.write(err.stack || String(err)); process.exit(1); });
|
|
123
|
+
JS
|
|
124
|
+
when 'php'
|
|
125
|
+
"file_put_contents(#{output_path.inspect}, json_encode($output));"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
module TransformBackend
|
|
5
|
+
# Code wrappers shared by Subprocess and Http backends. Each method
|
|
6
|
+
# emits the canonical dispatch harness — read stdin → run user code
|
|
7
|
+
# → find run/transform/result/passthrough → serialize output — and
|
|
8
|
+
# delegates the final write to a language-appropriate `output_sink`
|
|
9
|
+
# snippet supplied by the caller. Subprocess writes to a tempfile
|
|
10
|
+
# path, Http writes to FD 3 for the production daemon to capture.
|
|
11
|
+
#
|
|
12
|
+
# Mirrors the hosted serverless runtime's code-wrapping behavior
|
|
13
|
+
# verbatim except for the configurable sink.
|
|
14
|
+
#
|
|
15
|
+
# NOTE: `output_sink` is spliced verbatim into the generated script as
|
|
16
|
+
# executable code. It MUST be trmnlp-generated (see Subprocess#sink_for
|
|
17
|
+
# and Http#sink_for) and never derived from user input or config — an
|
|
18
|
+
# attacker-influenced sink is arbitrary code execution in the transform
|
|
19
|
+
# process. Only `code` is untrusted; the sink is part of the harness.
|
|
20
|
+
module Wrapper
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def python(code, output_sink)
|
|
24
|
+
<<~PYTHON
|
|
25
|
+
import sys, json, os
|
|
26
|
+
input = json.loads(sys.stdin.read())
|
|
27
|
+
|
|
28
|
+
#{code}
|
|
29
|
+
|
|
30
|
+
if callable(locals().get('run', None)):
|
|
31
|
+
output = run(input)
|
|
32
|
+
elif 'result' in dir():
|
|
33
|
+
output = result
|
|
34
|
+
else:
|
|
35
|
+
output = input
|
|
36
|
+
#{output_sink}
|
|
37
|
+
PYTHON
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ruby(code, output_sink)
|
|
41
|
+
<<~RUBY
|
|
42
|
+
require 'json'
|
|
43
|
+
input = JSON.parse($stdin.read)
|
|
44
|
+
|
|
45
|
+
#{code}
|
|
46
|
+
|
|
47
|
+
output = if defined?(run) == 'method'
|
|
48
|
+
run(input)
|
|
49
|
+
elsif defined?(result)
|
|
50
|
+
result
|
|
51
|
+
else
|
|
52
|
+
input
|
|
53
|
+
end
|
|
54
|
+
#{output_sink}
|
|
55
|
+
RUBY
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# NOTE: node also accepts `function transform(input)` for
|
|
59
|
+
# production parity. Plugins authored against the hosted service
|
|
60
|
+
# using `transform` would otherwise silently pass input through.
|
|
61
|
+
def node(code, output_sink)
|
|
62
|
+
<<~JS
|
|
63
|
+
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
64
|
+
|
|
65
|
+
#{code}
|
|
66
|
+
|
|
67
|
+
let output;
|
|
68
|
+
if (typeof run === "function") {
|
|
69
|
+
output = run(input);
|
|
70
|
+
} else if (typeof transform === "function") {
|
|
71
|
+
output = transform(input);
|
|
72
|
+
} else if (typeof result !== "undefined") {
|
|
73
|
+
output = result;
|
|
74
|
+
} else {
|
|
75
|
+
output = input;
|
|
76
|
+
}
|
|
77
|
+
#{output_sink}
|
|
78
|
+
JS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# NOTE: strips a leading `<?php` tag from user code so plugin
|
|
82
|
+
# authors can write the file as a standalone .php script. The
|
|
83
|
+
# hosted service does the same.
|
|
84
|
+
def php(code, output_sink)
|
|
85
|
+
cleaned = code.sub(/\A\s*<\?php\s*/, '')
|
|
86
|
+
<<~PHP
|
|
87
|
+
<?php
|
|
88
|
+
$input = json_decode(file_get_contents('php://stdin'), true);
|
|
89
|
+
|
|
90
|
+
#{cleaned}
|
|
91
|
+
|
|
92
|
+
if (function_exists('run')) {
|
|
93
|
+
$output = run($input);
|
|
94
|
+
} elseif (isset($result)) {
|
|
95
|
+
$output = $result;
|
|
96
|
+
} else {
|
|
97
|
+
$output = $input;
|
|
98
|
+
}
|
|
99
|
+
#{output_sink}
|
|
100
|
+
PHP
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def for(language, code, output_sink)
|
|
104
|
+
case language.to_s
|
|
105
|
+
when 'python' then python(code, output_sink)
|
|
106
|
+
when 'ruby' then ruby(code, output_sink)
|
|
107
|
+
when 'node' then node(code, output_sink)
|
|
108
|
+
when 'php' then php(code, output_sink)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|