trmnl_preview 0.7.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -6
  3. data/README.md +182 -7
  4. data/bin/rake +6 -6
  5. data/bin/trmnlp +2 -1
  6. data/db/data/form_fields.yml +24 -0
  7. data/db/data/framework_versions.yml +72 -0
  8. data/lib/trmnlp/api_client.rb +41 -28
  9. data/lib/trmnlp/app.rb +73 -44
  10. data/lib/trmnlp/browser_pool.rb +82 -0
  11. data/lib/trmnlp/cli.rb +24 -11
  12. data/lib/trmnlp/commands/base.rb +33 -10
  13. data/lib/trmnlp/commands/build.rb +13 -8
  14. data/lib/trmnlp/commands/clone.rb +12 -7
  15. data/lib/trmnlp/commands/init.rb +17 -13
  16. data/lib/trmnlp/commands/lint.rb +42 -0
  17. data/lib/trmnlp/commands/list.rb +40 -0
  18. data/lib/trmnlp/commands/login.rb +28 -13
  19. data/lib/trmnlp/commands/pull.rb +14 -6
  20. data/lib/trmnlp/commands/push.rb +29 -19
  21. data/lib/trmnlp/commands/serve.rb +32 -3
  22. data/lib/trmnlp/commands.rb +3 -1
  23. data/lib/trmnlp/config/app.rb +7 -4
  24. data/lib/trmnlp/config/plugin.rb +56 -14
  25. data/lib/trmnlp/config/project.rb +59 -7
  26. data/lib/trmnlp/config.rb +3 -1
  27. data/lib/trmnlp/context.rb +21 -224
  28. data/lib/trmnlp/errors.rb +15 -0
  29. data/lib/trmnlp/form_field.rb +42 -0
  30. data/lib/trmnlp/framework_version.rb +69 -0
  31. data/lib/trmnlp/image_quantizer.rb +58 -0
  32. data/lib/trmnlp/lint/check.rb +31 -0
  33. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  34. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  35. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  36. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  37. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  38. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  39. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  40. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  41. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  42. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  43. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  44. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  45. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  46. data/lib/trmnlp/lint/source.rb +42 -0
  47. data/lib/trmnlp/lint.rb +39 -0
  48. data/lib/trmnlp/paths.rb +28 -8
  49. data/lib/trmnlp/poller.rb +105 -0
  50. data/lib/trmnlp/renderer.rb +87 -0
  51. data/lib/trmnlp/reporter.rb +28 -0
  52. data/lib/trmnlp/screen.rb +16 -0
  53. data/lib/trmnlp/screen_generator.rb +11 -217
  54. data/lib/trmnlp/screenshot.rb +96 -0
  55. data/lib/trmnlp/transform_backend/http.rb +107 -0
  56. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  57. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  58. data/lib/trmnlp/transform_client.rb +47 -0
  59. data/lib/trmnlp/transform_pipeline.rb +65 -0
  60. data/lib/trmnlp/user_data_assembler.rb +96 -0
  61. data/lib/trmnlp/version.rb +1 -1
  62. data/lib/trmnlp/watcher.rb +60 -0
  63. data/lib/trmnlp.rb +6 -10
  64. data/templates/init/bin/trmnlp +1 -1
  65. data/templates/init/src/settings.yml +2 -1
  66. data/templates/init/src/transform.py.example +14 -0
  67. data/trmnl_preview.gemspec +34 -34
  68. data/web/public/index.css +6 -0
  69. data/web/public/index.js +31 -18
  70. data/web/public/trmnl-picker.js +2 -2
  71. data/web/views/index.erb +6 -1
  72. data/web/views/render_html.erb +4 -2
  73. metadata +81 -56
@@ -1,238 +1,32 @@
1
- require 'mini_magick'
2
- require 'selenium-webdriver'
3
- require 'base64'
4
- require 'thread'
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
- self.input = html
98
- self.image = !!opts[:image]
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
- convert_to_image
110
- image ? mono_image(output) : mono(output)
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 convert_to_image
117
- retry_count = 0
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