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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +319 -0
  5. data/bin/rake +27 -0
  6. data/bin/trmnlp +13 -14
  7. data/db/data/form_fields.yml +24 -0
  8. data/db/data/framework_versions.yml +72 -0
  9. data/lib/trmnlp/api_client.rb +99 -0
  10. data/lib/trmnlp/app.rb +132 -0
  11. data/lib/trmnlp/browser_pool.rb +82 -0
  12. data/lib/trmnlp/cli.rb +81 -0
  13. data/lib/trmnlp/commands/base.rb +63 -0
  14. data/lib/trmnlp/commands/build.rb +26 -0
  15. data/lib/trmnlp/commands/clone.rb +35 -0
  16. data/lib/trmnlp/commands/init.rb +59 -0
  17. data/lib/trmnlp/commands/lint.rb +42 -0
  18. data/lib/trmnlp/commands/list.rb +40 -0
  19. data/lib/trmnlp/commands/login.rb +42 -0
  20. data/lib/trmnlp/commands/pull.rb +51 -0
  21. data/lib/trmnlp/commands/push.rb +77 -0
  22. data/lib/trmnlp/commands/serve.rb +54 -0
  23. data/lib/trmnlp/commands.rb +3 -0
  24. data/lib/trmnlp/config/app.rb +50 -0
  25. data/lib/trmnlp/config/plugin.rb +117 -0
  26. data/lib/trmnlp/config/project.rb +106 -0
  27. data/lib/trmnlp/config.rb +17 -0
  28. data/lib/trmnlp/context.rb +35 -0
  29. data/lib/trmnlp/errors.rb +15 -0
  30. data/lib/trmnlp/form_field.rb +42 -0
  31. data/lib/trmnlp/framework_version.rb +69 -0
  32. data/lib/trmnlp/image_quantizer.rb +58 -0
  33. data/lib/trmnlp/lint/check.rb +31 -0
  34. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  35. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  36. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  37. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  38. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  39. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  40. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  41. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  42. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  43. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  44. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  45. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  46. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  47. data/lib/trmnlp/lint/source.rb +42 -0
  48. data/lib/trmnlp/lint.rb +39 -0
  49. data/lib/trmnlp/paths.rb +78 -0
  50. data/lib/trmnlp/poller.rb +105 -0
  51. data/lib/trmnlp/renderer.rb +87 -0
  52. data/lib/trmnlp/reporter.rb +28 -0
  53. data/lib/trmnlp/screen.rb +16 -0
  54. data/lib/trmnlp/screen_generator.rb +34 -0
  55. data/lib/trmnlp/screenshot.rb +96 -0
  56. data/lib/trmnlp/transform_backend/http.rb +107 -0
  57. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  58. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  59. data/lib/trmnlp/transform_client.rb +47 -0
  60. data/lib/trmnlp/transform_pipeline.rb +65 -0
  61. data/lib/trmnlp/user_data_assembler.rb +96 -0
  62. data/lib/trmnlp/version.rb +5 -0
  63. data/lib/trmnlp/watcher.rb +60 -0
  64. data/lib/trmnlp.rb +10 -0
  65. data/templates/init/.trmnlp.yml +14 -0
  66. data/templates/init/bin/trmnlp +44 -0
  67. data/templates/init/src/full.liquid +1 -0
  68. data/templates/init/src/half_horizontal.liquid +1 -0
  69. data/templates/init/src/half_vertical.liquid +1 -0
  70. data/templates/init/src/quadrant.liquid +1 -0
  71. data/templates/init/src/settings.yml +16 -0
  72. data/templates/init/src/shared.liquid +1 -0
  73. data/templates/init/src/transform.py.example +14 -0
  74. data/trmnl_preview.gemspec +68 -0
  75. data/web/public/highlight/highlight.min.js +315 -0
  76. data/web/public/highlight/styles/atom-one-dark.min.css +1 -0
  77. data/web/public/index.css +119 -0
  78. data/web/public/index.js +96 -0
  79. data/web/public/trmnl-picker.js +656 -0
  80. data/web/views/index.erb +73 -0
  81. data/web/views/render_html.erb +25 -0
  82. 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