trmnl_preview 0.7.2 → 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 +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/bin/rake +27 -0
- data/bin/trmnlp +13 -14
- data/db/data/form_fields.yml +24 -0
- data/db/data/framework_versions.yml +72 -0
- data/lib/trmnlp/api_client.rb +99 -0
- data/lib/trmnlp/app.rb +132 -0
- data/lib/trmnlp/browser_pool.rb +82 -0
- data/lib/trmnlp/cli.rb +81 -0
- data/lib/trmnlp/commands/base.rb +63 -0
- data/lib/trmnlp/commands/build.rb +26 -0
- data/lib/trmnlp/commands/clone.rb +35 -0
- data/lib/trmnlp/commands/init.rb +59 -0
- data/lib/trmnlp/commands/lint.rb +42 -0
- data/lib/trmnlp/commands/list.rb +40 -0
- data/lib/trmnlp/commands/login.rb +42 -0
- data/lib/trmnlp/commands/pull.rb +51 -0
- data/lib/trmnlp/commands/push.rb +77 -0
- data/lib/trmnlp/commands/serve.rb +54 -0
- data/lib/trmnlp/commands.rb +3 -0
- data/lib/trmnlp/config/app.rb +50 -0
- data/lib/trmnlp/config/plugin.rb +117 -0
- data/lib/trmnlp/config/project.rb +106 -0
- data/lib/trmnlp/config.rb +17 -0
- data/lib/trmnlp/context.rb +35 -0
- 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 +78 -0
- 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 +34 -0
- 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 +5 -0
- data/lib/trmnlp/watcher.rb +60 -0
- data/lib/trmnlp.rb +10 -0
- data/templates/init/.trmnlp.yml +14 -0
- data/templates/init/bin/trmnlp +44 -0
- data/templates/init/src/full.liquid +1 -0
- data/templates/init/src/half_horizontal.liquid +1 -0
- data/templates/init/src/half_vertical.liquid +1 -0
- data/templates/init/src/quadrant.liquid +1 -0
- data/templates/init/src/settings.yml +16 -0
- data/templates/init/src/shared.liquid +1 -0
- data/templates/init/src/transform.py.example +14 -0
- data/trmnl_preview.gemspec +68 -0
- data/web/public/highlight/highlight.min.js +315 -0
- data/web/public/highlight/styles/atom-one-dark.min.css +1 -0
- data/web/public/index.css +119 -0
- data/web/public/index.js +96 -0
- data/web/public/trmnl-picker.js +656 -0
- data/web/views/index.erb +73 -0
- data/web/views/render_html.erb +25 -0
- metadata +324 -15
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/conversions'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative 'reporter'
|
|
8
|
+
|
|
9
|
+
module TRMNLP
|
|
10
|
+
class Poller
|
|
11
|
+
def initialize(config:, paths:, reporter: Reporter.new)
|
|
12
|
+
@config = config
|
|
13
|
+
@paths = paths
|
|
14
|
+
@reporter = reporter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def poll_data
|
|
18
|
+
return unless config.plugin.polling?
|
|
19
|
+
raise InvalidConfig, 'config must specify polling_url or polling_urls' if config.plugin.polling_urls.empty?
|
|
20
|
+
|
|
21
|
+
data = aggregate_responses
|
|
22
|
+
write_user_data(data)
|
|
23
|
+
data
|
|
24
|
+
# NOTE: trmnlp is a dev tool — a flaky upstream API should surface a warning
|
|
25
|
+
# and keep the preview server alive, not crash the user's session. We
|
|
26
|
+
# deliberately swallow here and return {} so the renderer keeps rendering.
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
reporter.info("warning: #{e.message}")
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def put_webhook(payload)
|
|
33
|
+
write_user_data(wrap_array(JSON.parse(payload)))
|
|
34
|
+
# NOTE: Same rationale as #poll_data — a bad webhook payload shouldn't take
|
|
35
|
+
# down the dev server. Report a warning and keep serving.
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
reporter.info("webhook warning: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :config, :paths, :reporter
|
|
43
|
+
|
|
44
|
+
def aggregate_responses
|
|
45
|
+
responses = config.plugin.polling_urls.map { |url| fetch_one(url) }
|
|
46
|
+
return responses.first if responses.size == 1
|
|
47
|
+
|
|
48
|
+
responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch_one(url)
|
|
52
|
+
verb = config.plugin.polling_verb.upcase
|
|
53
|
+
response = perform_request(url, verb)
|
|
54
|
+
reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
|
|
55
|
+
parse_response(response)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def perform_request(url, verb)
|
|
59
|
+
conn = Faraday.new(url:, headers: config.plugin.polling_headers)
|
|
60
|
+
verb == 'POST' ? conn.post { |req| req.body = config.plugin.polling_body } : conn.get
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_response(response)
|
|
64
|
+
return parse_failure(response.body) unless response.status == 200
|
|
65
|
+
|
|
66
|
+
parse_body(response.body, response.headers['content-type'])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_failure(body)
|
|
70
|
+
reporter.info(body)
|
|
71
|
+
{}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_body(body, content_type_header)
|
|
75
|
+
content_type = content_type_header&.split(';')&.first&.strip
|
|
76
|
+
case content_type
|
|
77
|
+
when 'application/json', %r{^application/.+\+json} then wrap_array(JSON.parse(body))
|
|
78
|
+
when 'text/xml', 'application/xml', %r{^application/.+\+xml} then wrap_array(Hash.from_xml(body))
|
|
79
|
+
when 'text/html', 'text/plain' then sniff_json(body) || { 'text' => body }
|
|
80
|
+
else log_unknown_type(content_type_header)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log_unknown_type(header)
|
|
85
|
+
reporter.info("unknown content type received: #{header}")
|
|
86
|
+
{}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
|
|
90
|
+
|
|
91
|
+
def sniff_json(body)
|
|
92
|
+
trimmed = body.to_s.strip
|
|
93
|
+
return nil unless trimmed.start_with?('{', '[')
|
|
94
|
+
|
|
95
|
+
wrap_array(JSON.parse(trimmed))
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write_user_data(data)
|
|
101
|
+
paths.create_cache_dir
|
|
102
|
+
paths.user_data.write(JSON.generate(data))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'trmnl/liquid'
|
|
5
|
+
|
|
6
|
+
require_relative 'screen'
|
|
7
|
+
|
|
8
|
+
module TRMNLP
|
|
9
|
+
class Renderer
|
|
10
|
+
def initialize(config:, paths:, user_data_assembler:)
|
|
11
|
+
@config = config
|
|
12
|
+
@paths = paths
|
|
13
|
+
@user_data_assembler = user_data_assembler
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render_full_page(view, params = {})
|
|
17
|
+
device = user_data_assembler.device_from_params(params)
|
|
18
|
+
binding_obj = TemplateBinding.new(self, view, params)
|
|
19
|
+
ERB.new(paths.render_template.read).result(
|
|
20
|
+
binding_obj.get_binding { render_or_error(view, device:) }
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def framework = config.plugin.framework_version
|
|
25
|
+
|
|
26
|
+
def screen_classes(classes = 'screen')
|
|
27
|
+
classes ||= 'screen' # an explicit nil (omitted screen_classes param) still needs a base
|
|
28
|
+
classes += ' screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
|
|
29
|
+
classes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :config, :paths, :user_data_assembler
|
|
35
|
+
|
|
36
|
+
# NOTE: a missing template or Liquid syntax error is a user-facing
|
|
37
|
+
# signal — the plugin author needs to *see* what broke. We surface
|
|
38
|
+
# those as RenderError, then #render_or_error embeds the message
|
|
39
|
+
# inside the preview frame so the dev server keeps serving instead
|
|
40
|
+
# of 500-ing. Anything that's not a RenderError bubbles up as a bug.
|
|
41
|
+
def render_liquid_template(view, device: {})
|
|
42
|
+
template_path = paths.template(view)
|
|
43
|
+
raise RenderError, "Missing template: #{template_path}" unless template_path.exist?
|
|
44
|
+
|
|
45
|
+
parse_and_render(template_path, device:)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_and_render(template_path, device:)
|
|
49
|
+
Liquid::Template.parse(full_markup(template_path), environment: liquid_environment)
|
|
50
|
+
.render(user_data_assembler.call(device:))
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
raise RenderError, e.message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_or_error(view, device:)
|
|
56
|
+
render_liquid_template(view, device:)
|
|
57
|
+
rescue RenderError => e
|
|
58
|
+
e.message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def full_markup(template_path)
|
|
62
|
+
shared = paths.shared_template
|
|
63
|
+
shared.exist? ? shared.read + template_path.read : template_path.read
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def liquid_environment
|
|
67
|
+
@liquid_environment ||= TRMNL::Liquid.new do |env|
|
|
68
|
+
config.project.user_filters.each do |module_name, relative_path|
|
|
69
|
+
require paths.root_dir.join(relative_path)
|
|
70
|
+
env.register_filter(Object.const_get(module_name))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# bindings must match the `GET /render/{view}.html` route in app.rb
|
|
76
|
+
class TemplateBinding
|
|
77
|
+
def initialize(renderer, view, params)
|
|
78
|
+
@view = view
|
|
79
|
+
@screen_classes = renderer.screen_classes(params[:screen_classes])
|
|
80
|
+
@framework = renderer.framework
|
|
81
|
+
@mashup_classes = Screen.find(view)&.mashup_classes
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get_binding = binding
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
# Single sink for user-facing command output. Records every message so
|
|
5
|
+
# specs can assert on what a command would have said, and writes to the
|
|
6
|
+
# underlying stream unless quiet:.
|
|
7
|
+
class Reporter
|
|
8
|
+
attr_reader :messages
|
|
9
|
+
|
|
10
|
+
def initialize(quiet: false, stream: $stdout)
|
|
11
|
+
@quiet = quiet
|
|
12
|
+
@stream = stream
|
|
13
|
+
@messages = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def info(message)
|
|
17
|
+
@messages << message
|
|
18
|
+
@stream.puts(message) unless @quiet
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def green(text) = colorize(text, 32)
|
|
22
|
+
def yellow(text) = colorize(text, 33)
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def colorize(text, code) = "\e[#{code}m#{text}\e[0m"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
class Screen < Data.define(:name, :mashup_classes)
|
|
5
|
+
FULL = new(name: 'full', mashup_classes: nil)
|
|
6
|
+
HALF_HORIZONTAL = new(name: 'half_horizontal', mashup_classes: 'mashup mashup--1Tx1B')
|
|
7
|
+
HALF_VERTICAL = new(name: 'half_vertical', mashup_classes: 'mashup mashup--1Lx1R')
|
|
8
|
+
QUADRANT = new(name: 'quadrant', mashup_classes: 'mashup mashup--2x2')
|
|
9
|
+
|
|
10
|
+
ALL = [FULL, HALF_HORIZONTAL, HALF_VERTICAL, QUADRANT].freeze
|
|
11
|
+
|
|
12
|
+
def self.all = ALL
|
|
13
|
+
def self.find(name) = ALL.find { it.name == name }
|
|
14
|
+
def self.names = ALL.map(&:name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'screenshot'
|
|
4
|
+
require_relative 'image_quantizer'
|
|
5
|
+
|
|
6
|
+
module TRMNLP
|
|
7
|
+
class ScreenGenerator
|
|
8
|
+
def initialize(html, opts = {})
|
|
9
|
+
@input = html
|
|
10
|
+
@screenshot = opts[:screenshot]
|
|
11
|
+
@requested_width = opts[:width]
|
|
12
|
+
@requested_height = opts[:height]
|
|
13
|
+
@requested_color_depth = opts[:color_depth]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def process
|
|
17
|
+
output = @screenshot.call(html: @input, width:, height:)
|
|
18
|
+
ImageQuantizer.new(depth: color_depth).call(output.path)
|
|
19
|
+
output
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def width = @requested_width || 800
|
|
25
|
+
def height = @requested_height || 480
|
|
26
|
+
|
|
27
|
+
def color_depth
|
|
28
|
+
return @requested_color_depth if @requested_color_depth
|
|
29
|
+
return ::Regexp.last_match(1).to_i if @input&.match(/screen--(\d+)bit/)
|
|
30
|
+
|
|
31
|
+
1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
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
|