herb 0.9.7-arm-linux-gnu → 0.10.0-arm-linux-gnu

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/ext/herb/extconf.rb +1 -0
  4. data/ext/herb/extension.c +108 -0
  5. data/herb.gemspec +1 -1
  6. data/lib/herb/3.2/herb.so +0 -0
  7. data/lib/herb/3.3/herb.so +0 -0
  8. data/lib/herb/3.4/herb.so +0 -0
  9. data/lib/herb/4.0/herb.so +0 -0
  10. data/lib/herb/action_view/render_analyzer.rb +1057 -0
  11. data/lib/herb/ast/erb_render_node.rb +155 -0
  12. data/lib/herb/bootstrap.rb +0 -1
  13. data/lib/herb/cli.rb +253 -19
  14. data/lib/herb/colors.rb +18 -0
  15. data/lib/herb/configuration.rb +49 -13
  16. data/lib/herb/defaults.yml +3 -0
  17. data/lib/herb/dev/runner.rb +445 -0
  18. data/lib/herb/dev/server.rb +207 -0
  19. data/lib/herb/dev/server_entry.rb +128 -0
  20. data/lib/herb/diff_operation.rb +34 -0
  21. data/lib/herb/diff_result.rb +59 -0
  22. data/lib/herb/engine/compiler.rb +56 -3
  23. data/lib/herb/engine/validators/render_validator.rb +92 -0
  24. data/lib/herb/engine.rb +58 -4
  25. data/lib/herb/html/util.rb +16 -0
  26. data/lib/herb/project.rb +1 -6
  27. data/lib/herb/version.rb +1 -1
  28. data/lib/herb.rb +41 -5
  29. data/sig/herb/action_view/render_analyzer.rbs +122 -0
  30. data/sig/herb/ast/erb_render_node.rbs +29 -0
  31. data/sig/herb/colors.rbs +12 -0
  32. data/sig/herb/configuration.rbs +20 -1
  33. data/sig/herb/dev/runner.rbs +59 -0
  34. data/sig/herb/dev/server.rbs +50 -0
  35. data/sig/herb/dev/server_entry.rbs +51 -0
  36. data/sig/herb/diff_operation.rbs +34 -0
  37. data/sig/herb/diff_result.rbs +34 -0
  38. data/sig/herb/engine/compiler.rbs +6 -0
  39. data/sig/herb/engine/validators/render_validator.rbs +21 -0
  40. data/sig/herb/engine.rbs +15 -0
  41. data/sig/herb/html/util.rbs +13 -0
  42. data/sig/herb.rbs +12 -2
  43. data/sig/herb_c_extension.rbs +1 -1
  44. data/sig/vendor/did_you_mean.rbs +6 -0
  45. data/sig/vendor/parallel.rbs +4 -0
  46. data/src/analyze/action_view/attribute_extraction_helpers.c +3 -2
  47. data/src/diff/herb_diff.c +137 -0
  48. data/src/diff/herb_diff_attributes.c +207 -0
  49. data/src/diff/herb_diff_children.c +518 -0
  50. data/src/diff/herb_diff_helpers.c +114 -0
  51. data/src/diff/herb_diff_nodes.c +707 -0
  52. data/src/diff/herb_hash.c +42 -0
  53. data/src/diff/herb_hash_index_map.c +47 -0
  54. data/src/diff/herb_hash_map.c +104 -0
  55. data/src/diff/herb_hash_tree.c +680 -0
  56. data/src/include/diff/herb_diff.h +118 -0
  57. data/src/include/diff/herb_hash.h +25 -0
  58. data/src/include/diff/herb_hash_index_map.h +32 -0
  59. data/src/include/diff/herb_hash_map.h +30 -0
  60. data/src/include/herb.h +1 -0
  61. data/src/include/version.h +1 -1
  62. data/templates/javascript/packages/core/src/config.ts.erb +43 -0
  63. data/templates/rust/src/ast/nodes.rs.erb +1 -1
  64. data/templates/rust/src/config.rs.erb +50 -0
  65. data/templates/src/diff/herb_diff_helpers.c.erb +38 -0
  66. data/templates/src/diff/herb_diff_nodes.c.erb +224 -0
  67. data/templates/src/diff/herb_hash_tree.c.erb +147 -0
  68. data/templates/template.rb +4 -4
  69. metadata +40 -4
  70. data/lib/herb/3.0/herb.so +0 -0
  71. data/lib/herb/3.1/herb.so +0 -0
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+ # typed: ignore
3
+
4
+ require_relative "../colors"
5
+
6
+ module Herb
7
+ module Dev
8
+ class Runner
9
+ include Herb::Colors
10
+
11
+ PATCHABLE_TYPES = ["text_changed", "attribute_value_changed", "attribute_added", "attribute_removed"].freeze #: Array[String]
12
+
13
+ def self.can_patch?(operations)
14
+ operations.all? { |operation|
15
+ next false unless PATCHABLE_TYPES.include?(operation.type.to_s)
16
+ next false if operation.new_node&.type&.to_s&.include?("ERB")
17
+ next false if operation.old_node&.type&.to_s&.include?("ERB")
18
+
19
+ true
20
+ }
21
+ end
22
+
23
+ CLEAR_SCREEN = "\e[2J\e[H"
24
+ HIDE_CURSOR = "\e[?25l"
25
+ SHOW_CURSOR = "\e[?25h"
26
+
27
+ def initialize(path: ".", cli: nil)
28
+ @path = path
29
+ @cli = cli
30
+ end
31
+
32
+ def run
33
+ require_cruise
34
+ require_relative "server"
35
+
36
+ unless File.directory?(@path)
37
+ puts "Not a directory: '#{@path}'."
38
+ exit(1)
39
+ end
40
+
41
+ config = Herb::Configuration.load(@path)
42
+ expanded_path = File.realpath(File.expand_path(config.project_root || @path))
43
+
44
+ check_existing_server(expanded_path)
45
+ port = find_port
46
+
47
+ print CLEAR_SCREEN
48
+ print HIDE_CURSOR
49
+ print_header(config, expanded_path)
50
+
51
+ file_states = index_files(config, @path)
52
+
53
+ websocket = Herb::Dev::Server.new(port: port, project_path: expanded_path)
54
+ websocket.start
55
+
56
+ puts " #{fg("WebSocket:".ljust(11), 245)}#{fg("ws://localhost:#{websocket.port}", 250)}"
57
+ puts
58
+ puts " #{fg("Ready!", 42)} #{fg("Watching for changes...", 241)}"
59
+ puts
60
+
61
+ watch_files(config, expanded_path, websocket, file_states)
62
+ rescue Interrupt
63
+ websocket&.stop
64
+ print SHOW_CURSOR
65
+ puts
66
+ puts "Stopped."
67
+ exit(0)
68
+ ensure
69
+ websocket&.stop
70
+ print SHOW_CURSOR
71
+ end
72
+
73
+ def stop
74
+ require_relative "server"
75
+
76
+ entries = Herb::Dev::ServerEntry.all
77
+
78
+ if entries.empty?
79
+ puts "No herb dev servers running."
80
+ exit(0)
81
+ end
82
+
83
+ entries.each do |entry|
84
+ entry.stop!
85
+ puts "Stopped herb dev server for #{entry.project_name} (PID: #{entry.pid}, port: #{entry.port})"
86
+ end
87
+
88
+ exit(0)
89
+ end
90
+
91
+ def restart
92
+ require_relative "server"
93
+
94
+ Herb::Dev::ServerEntry.all.each(&:stop!)
95
+ sleep 0.5
96
+ run
97
+ end
98
+
99
+ def status
100
+ require_relative "server"
101
+
102
+ entries = Herb::Dev::ServerEntry.all
103
+
104
+ if entries.empty?
105
+ puts "No herb dev servers running."
106
+ else
107
+ entries.each do |entry|
108
+ puts "#{entry.project_name} — PID: #{entry.pid}, port: #{entry.port}, started: #{entry.started_at}"
109
+ end
110
+ end
111
+
112
+ exit(0)
113
+ end
114
+
115
+ private
116
+
117
+ def pluralize(count, word)
118
+ "#{count} #{word}#{"s" unless count == 1}"
119
+ end
120
+
121
+ def require_cruise
122
+ require "cruise"
123
+ rescue LoadError
124
+ abort <<~MESSAGE
125
+ The 'cruise' gem is required for the Herb Dev Server.
126
+
127
+ Install it:
128
+ gem install cruise
129
+
130
+ or add to your Gemfile:
131
+ bundle add cruise
132
+ MESSAGE
133
+ end
134
+
135
+ def check_existing_server(expanded_path)
136
+ existing = Herb::Dev::ServerEntry.find_by_project(expanded_path)
137
+
138
+ return unless existing
139
+
140
+ puts "Herb dev server is already running for this project (PID: #{existing.pid}, port: #{existing.port})."
141
+ puts
142
+ puts " herb dev stop Stop the running server"
143
+ puts " herb dev restart Restart the server"
144
+ exit(1)
145
+ end
146
+
147
+ def find_port
148
+ port = Herb::Dev::Server::DEFAULT_PORT
149
+ port_owner = Herb::Dev::ServerEntry.find_by_port(port)
150
+
151
+ if port_owner
152
+ port = Herb::Dev::Server.find_available_port(port + 1)
153
+ abort "No available ports found" unless port
154
+ end
155
+
156
+ port
157
+ end
158
+
159
+ def print_header(config, expanded_path)
160
+ puts
161
+ puts fg_bg(" \u{1F33F} Herb Dev Server ", 255, 28)
162
+ puts
163
+ puts " #{fg("\u26A0\uFE0F Experimental:", 214)} #{fg("The dev server is experimental and may not work correctly in all cases.", 241)}"
164
+ puts
165
+
166
+ puts " #{fg("Herb:".ljust(11), 245)}#{fg(Herb::VERSION, 250)}"
167
+ puts " #{fg("Project:".ljust(11), 245)}#{fg(expanded_path, 250)}"
168
+ puts " #{fg("PID:".ljust(11), 245)}#{fg(Process.pid, 250)} #{fg("(#{File.join(Herb::Dev::ServerEntry::SERVERS_DIR, "#{Process.pid}.json")})", 241)}"
169
+
170
+ if config.config_path
171
+ relative_config = config.config_path.to_s.delete_prefix("#{expanded_path}/")
172
+ puts " #{fg("Config:".ljust(11), 245)}#{fg(relative_config, 250)}"
173
+ else
174
+ puts " #{fg("Config:".ljust(11), 245)}#{fg("(defaults)", 241)}"
175
+ end
176
+ end
177
+
178
+ def index_files(config, path)
179
+ puts " #{fg("Indexing files...", 241)}"
180
+
181
+ file_states = {}
182
+ initial_files = config.find_files(path)
183
+
184
+ initial_files.each do |file_path|
185
+ file_states[file_path] = File.read(file_path)
186
+ rescue StandardError
187
+ # skip files that can't be read
188
+ end
189
+
190
+ print "\e[1A\e[2K"
191
+ puts " #{fg("Files:".ljust(11), 245)}#{fg("#{file_states.size} templates indexed", 250)}"
192
+
193
+ file_states
194
+ end
195
+
196
+ def watch_files(config, expanded_path, websocket, file_states)
197
+ include_patterns = config.file_include_patterns
198
+ exclude_patterns = config.file_exclude_patterns
199
+ first_change = true
200
+ errored_files = Set.new
201
+
202
+ Thread.new do
203
+ $stdin.gets(nil)
204
+ Thread.main.raise(Interrupt)
205
+ rescue IOError, Errno::EBADF
206
+ Thread.main.raise(Interrupt)
207
+ end
208
+
209
+ Cruise.watch(expanded_path, only: ["created", "modified", "removed"]) do |event|
210
+ file_path = event.path
211
+ relative_path = file_path.delete_prefix("#{expanded_path}/")
212
+
213
+ next if config.path_excluded?(relative_path, exclude_patterns)
214
+ next unless config.path_included?(relative_path, include_patterns)
215
+
216
+ if first_change
217
+ print "\e[2A\e[J"
218
+ puts " #{fg("Recent changes:", 245)}"
219
+ puts
220
+ first_change = false
221
+ end
222
+
223
+ timestamp = fg(Time.now.strftime("%H:%M:%S.%L"), 241)
224
+ display_path = fg(relative_path, 250)
225
+
226
+ case event.kind
227
+ when "created", "modified"
228
+ handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path)
229
+ when "removed"
230
+ file_states.delete(file_path)
231
+ badge = bold(fg("- removed", 196))
232
+ puts " #{timestamp} #{badge} #{display_path}"
233
+ end
234
+ end
235
+ end
236
+
237
+ def handle_file_change(file_path, relative_path, file_states, errored_files, websocket, timestamp, display_path)
238
+ return unless File.exist?(file_path)
239
+
240
+ current_content = File.read(file_path)
241
+ previous_content = file_states[file_path]
242
+
243
+ if previous_content.nil?
244
+ file_states[file_path] = current_content
245
+ badge = bold(fg("+ added ", 42))
246
+ puts " #{timestamp} #{badge} #{display_path}"
247
+ return
248
+ end
249
+
250
+ return if previous_content == current_content && !errored_files.include?(file_path)
251
+
252
+ current_parse = Herb.parse(current_content, strict: true, analyze: true)
253
+
254
+ if current_parse.errors.any?
255
+ broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path)
256
+ return
257
+ end
258
+
259
+ if errored_files.delete?(file_path)
260
+ broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path)
261
+ return
262
+ end
263
+
264
+ handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path)
265
+ end
266
+
267
+ def broadcast_errors(file_path, relative_path, current_parse, previous_content, errored_files, websocket, timestamp, display_path)
268
+ current_errors = current_parse.errors
269
+
270
+ previous_parse = Herb.parse(previous_content, strict: true, analyze: true)
271
+ previous_errors = previous_parse.errors
272
+
273
+ new_errors = current_errors.select { |error|
274
+ previous_errors.none? { |previous_error|
275
+ previous_error.error_name == error.error_name && previous_error.location.start.line == error.location.start.line
276
+ }
277
+ }
278
+
279
+ badge = bold(fg("\u{2717} error ", 196))
280
+ puts " #{timestamp} #{badge} #{display_path} #{fg("(#{pluralize(current_errors.size, "error")})", 241)}"
281
+
282
+ new_errors.each do |error|
283
+ location = fg("#{relative_path}:#{error.location.start.line}:#{error.location.start.column}", 241)
284
+ puts " #{fg(error.error_name, 196)} #{location}"
285
+ puts " #{fg(error.message, 250)}" if error.message && !error.message.empty?
286
+ end
287
+
288
+ errored_files.add(file_path)
289
+
290
+ if websocket.client_count.positive?
291
+ websocket.broadcast({
292
+ type: "error",
293
+ file: relative_path,
294
+ errors: current_errors.map { |error|
295
+ {
296
+ name: error.error_name,
297
+ message: error.message,
298
+ line: error.location.start.line,
299
+ column: error.location.start.column,
300
+ }
301
+ },
302
+ })
303
+ end
304
+
305
+ puts
306
+ end
307
+
308
+ def broadcast_fixed(file_path, relative_path, current_content, file_states, websocket, timestamp, display_path)
309
+ file_states[file_path] = current_content
310
+ badge = bold(fg("\u{2713} fixed ", 42))
311
+ puts " #{timestamp} #{badge} #{display_path}"
312
+
313
+ if websocket.client_count.positive?
314
+ websocket.broadcast({ type: "fixed", file: relative_path })
315
+ end
316
+
317
+ puts
318
+ end
319
+
320
+ def handle_diff(file_path, relative_path, current_content, previous_content, file_states, websocket, timestamp, display_path)
321
+ diff_result = Herb.diff(previous_content, current_content)
322
+ file_states[file_path] = current_content
323
+
324
+ return if diff_result.identical?
325
+
326
+ operations = diff_result.operations
327
+ can_patch = self.class.can_patch?(operations)
328
+
329
+ if can_patch && websocket.client_count.positive?
330
+ patch_operations = operations.map do |operation|
331
+ {
332
+ type: operation.type.to_s,
333
+ path: operation.path,
334
+ old_value: extract_node_value(operation.old_node),
335
+ new_value: extract_node_value(operation.new_node),
336
+ old_node_type: operation.old_node&.type,
337
+ new_node_type: operation.new_node&.type,
338
+ }
339
+ end
340
+
341
+ websocket.broadcast({
342
+ type: "patch",
343
+ file: relative_path,
344
+ operations: patch_operations,
345
+ })
346
+ elsif !can_patch && websocket.client_count.positive?
347
+ websocket.broadcast({
348
+ type: "reload",
349
+ file: relative_path,
350
+ })
351
+ end
352
+
353
+ print_diff_summary(operations, can_patch, websocket, timestamp, display_path)
354
+ end
355
+
356
+ def print_diff_summary(operations, can_patch, websocket, timestamp, display_path)
357
+ badge = if can_patch
358
+ bold(fg("\u{2713} patch ", 42))
359
+ else
360
+ bold(fg("\u{21BB} reload ", 214))
361
+ end
362
+
363
+ clients_label = websocket.client_count.positive? ? " #{fg("[#{pluralize(websocket.client_count, "client")}]", 241)}" : ""
364
+ puts " #{timestamp} #{badge} #{display_path} #{fg("(#{pluralize(operations.size, "operation")})", 241)}#{clients_label}"
365
+
366
+ operations.each_with_index do |operation, index|
367
+ type = operation.type.to_s
368
+
369
+ type_color = case type
370
+ when "node_inserted" then 114
371
+ when "node_removed", "attribute_removed" then 168
372
+ when "node_replaced", "tag_name_changed" then 173
373
+ when "node_wrapped", "node_unwrapped", "attribute_added", "attribute_value_changed" then 75
374
+ when "node_moved" then 73
375
+ when "text_changed" then 186
376
+ when "erb_content_changed" then 176
377
+ else 241
378
+ end
379
+
380
+ type_label = type.tr("_", " ")
381
+ index_label = fg("##{index + 1}", 241)
382
+ path_label = fg("[#{operation.path.join(", ")}]", 241)
383
+ indent = " "
384
+
385
+ puts "#{indent}#{index_label} #{bold(fg(type_label, type_color))} #{path_label}"
386
+
387
+ print_diff_node(indent, "-", 168, operation.old_node, type) if operation.old_node
388
+ print_diff_node(indent, "+", 114, operation.new_node, type) if operation.new_node
389
+ end
390
+
391
+ puts
392
+ end
393
+
394
+ def print_diff_node(indent, sign, color, node, _type)
395
+ value = extract_node_value(node)
396
+
397
+ if value
398
+ value.split("\n").each do |line|
399
+ puts "#{indent} #{fg(sign, color)} #{fg(line, color)}"
400
+ end
401
+ else
402
+ label = node.type.to_s.sub("AST_", "").sub("_NODE", "")
403
+ location = node.location ? " (#{node.location.start.line}:#{node.location.start.column})" : ""
404
+ puts "#{indent} #{fg(sign, color)} #{fg("#{label}#{location}", color)}"
405
+ end
406
+ end
407
+
408
+ def extract_node_value(node)
409
+ return nil unless node
410
+
411
+ if node.is_a?(Herb::AST::HTMLTextNode)
412
+ return node.content&.to_s
413
+ end
414
+
415
+ if node.is_a?(Herb::AST::ERBContentNode)
416
+ return node.content&.value&.to_s
417
+ end
418
+
419
+ if node.is_a?(Herb::AST::HTMLElementNode)
420
+ name = node.tag_name
421
+ name = name.value if name.respond_to?(:value)
422
+ return "<#{name}>"
423
+ end
424
+
425
+ if node.is_a?(Herb::AST::HTMLAttributeNode)
426
+ parts = []
427
+
428
+ if node.name.respond_to?(:children)
429
+ parts << node.name.children.map { |child| child.respond_to?(:content) ? child.content.to_s : "" }.join
430
+ end
431
+
432
+ if node.value.respond_to?(:children)
433
+ value = node.value.children.map { |child| child.respond_to?(:content) ? child.content.to_s : "" }.join
434
+ parts << "=\"#{value}\""
435
+ end
436
+
437
+ result = parts.join
438
+ return result.empty? ? nil : result
439
+ end
440
+
441
+ nil
442
+ end
443
+ end
444
+ end
445
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "socket"
5
+ require "json"
6
+
7
+ begin
8
+ require "websocket"
9
+ rescue LoadError
10
+ require "bundler/inline"
11
+
12
+ gemfile do
13
+ source "https://rubygems.org"
14
+ gem "websocket"
15
+ end
16
+
17
+ require "websocket"
18
+ end
19
+
20
+ require_relative "server_entry"
21
+
22
+ module Herb
23
+ module Dev
24
+ class Server
25
+ DEFAULT_PORT = 8592
26
+ HANDSHAKE_TIMEOUT = 5
27
+
28
+ attr_reader :port, :project_path
29
+
30
+ Client = Data.define(:socket, :version, :mutex)
31
+
32
+ def initialize(port: DEFAULT_PORT, project_path: nil)
33
+ @port = port
34
+ @project_path = project_path
35
+ @clients = [] #: Array[Client]
36
+ @mutex = Mutex.new
37
+ @server = nil
38
+ @accept_thread = nil
39
+ @entry = nil
40
+ end
41
+
42
+ def start
43
+ @entry = ServerEntry.new(pid: Process.pid, port: @port, project: @project_path)
44
+ @entry.save
45
+ @server = TCPServer.new("0.0.0.0", @port)
46
+
47
+ @accept_thread = Thread.new do
48
+ loop do
49
+ socket = @server.accept
50
+ Thread.new(socket) { |s| handle_connection(s) }
51
+ rescue IOError
52
+ break
53
+ end
54
+ end
55
+ end
56
+
57
+ def stop
58
+ @mutex.synchronize do
59
+ @clients.each { |client| safely_close(client.socket) }
60
+ @clients.clear
61
+ end
62
+
63
+ safely_close(@server)
64
+
65
+ @accept_thread&.kill
66
+ @entry&.remove
67
+ end
68
+
69
+ def broadcast(message)
70
+ data = message.is_a?(String) ? message : JSON.generate(message)
71
+
72
+ failed_clients = []
73
+ clients_snapshot = @mutex.synchronize { @clients.dup }
74
+
75
+ clients_snapshot.each do |client|
76
+ frame = WebSocket::Frame::Outgoing::Server.new(version: client.version, data: data, type: :text)
77
+
78
+ client.mutex.synchronize do
79
+ client.socket.write(frame.to_s)
80
+ client.socket.flush
81
+ end
82
+ rescue StandardError
83
+ safely_close(client.socket)
84
+
85
+ failed_clients << client
86
+ end
87
+
88
+ return unless failed_clients.any?
89
+
90
+ @mutex.synchronize do
91
+ failed_clients.each { |client| @clients.delete(client) }
92
+ end
93
+ end
94
+
95
+ def client_count
96
+ @mutex.synchronize { @clients.size }
97
+ end
98
+
99
+ def self.port_available?(port)
100
+ server = TCPServer.new("0.0.0.0", port)
101
+ server.close
102
+
103
+ true
104
+ rescue Errno::EADDRINUSE
105
+ false
106
+ end
107
+
108
+ def self.find_available_port(starting_port = DEFAULT_PORT)
109
+ port = starting_port
110
+
111
+ loop do
112
+ return port if port_available?(port)
113
+
114
+ port += 1
115
+ break if port > starting_port + 100
116
+ end
117
+
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ def safely_close(resource)
124
+ resource&.close
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ def handle_connection(socket)
130
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
131
+
132
+ handshake = WebSocket::Handshake::Server.new
133
+
134
+ until handshake.finished?
135
+ readable = socket.wait_readable(HANDSHAKE_TIMEOUT)
136
+
137
+ unless readable
138
+ socket.close
139
+ return
140
+ end
141
+
142
+ data = socket.read_nonblock(4096, exception: false)
143
+ break if data.nil? || data == :wait_readable
144
+
145
+ data.each_byte { |byte| handshake << byte.chr }
146
+ end
147
+
148
+ unless handshake.valid?
149
+ socket.close
150
+ return
151
+ end
152
+
153
+ socket.write(handshake.to_s)
154
+ socket.flush
155
+
156
+ welcome = WebSocket::Frame::Outgoing::Server.new(
157
+ version: handshake.version,
158
+ data: JSON.generate({ type: "welcome", project: @project_path }),
159
+ type: :text
160
+ )
161
+
162
+ socket.write(welcome.to_s)
163
+ socket.flush
164
+
165
+ client = Client.new(socket: socket, version: handshake.version, mutex: Mutex.new)
166
+ @mutex.synchronize { @clients << client }
167
+
168
+ frame_parser = WebSocket::Frame::Incoming::Server.new(version: handshake.version)
169
+
170
+ loop do
171
+ chunk = socket.readpartial(4096)
172
+
173
+ frame_parser << chunk
174
+
175
+ while (frame = frame_parser.next)
176
+ case frame.type
177
+ when :close
178
+ close_frame = WebSocket::Frame::Outgoing::Server.new(version: handshake.version, data: "", type: :close)
179
+
180
+ begin
181
+ client.mutex.synchronize { socket.write(close_frame.to_s) }
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ return
187
+ when :ping
188
+ pong = WebSocket::Frame::Outgoing::Server.new(version: handshake.version, data: frame.data, type: :pong)
189
+
190
+ client.mutex.synchronize { socket.write(pong.to_s) }
191
+ when :text
192
+ nil
193
+ end
194
+ end
195
+ end
196
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE
197
+ # client disconnected
198
+ rescue StandardError => e
199
+ warn "[herb-dev-server] connection error: #{e.class}: #{e.message}"
200
+ ensure
201
+ @mutex.synchronize { @clients.delete_if { |client| client.socket == socket } }
202
+
203
+ safely_close(socket)
204
+ end
205
+ end
206
+ end
207
+ end