datastar 1.0.0 → 1.0.2

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.
@@ -0,0 +1,307 @@
1
+ require 'bundler'
2
+ Bundler.setup(:test)
3
+
4
+ require 'datastar'
5
+
6
+ # This is a demo Rack app to showcase patching components and signals
7
+ # from the server to the client.
8
+ # To run:
9
+ #
10
+ # # install dependencies
11
+ # bundle install
12
+ # # run this endpoint with Puma server
13
+ # bundle exec puma ./progress.ru
14
+ #
15
+ # Then open http://localhost:9292
16
+ #
17
+ # A Web Component for circular progress
18
+ # Progress is controlled by a `progress` signal
19
+ PROGRESS = <<~JAVASCRIPT
20
+ class CircularProgress extends HTMLElement {
21
+ constructor() {
22
+ super();
23
+ this.attachShadow({ mode: 'open' });
24
+ this._progress = 0;
25
+ this.radius = 90;
26
+ this.circumference = 2 * Math.PI * this.radius;
27
+ }
28
+
29
+ static get observedAttributes() {
30
+ return ['progress'];
31
+ }
32
+
33
+ get progress() {
34
+ return this._progress;
35
+ }
36
+
37
+ attributeChangedCallback(name, oldValue, newValue) {
38
+ if (name === 'progress' && oldValue !== newValue) {
39
+ this._progress = Math.max(0, Math.min(100, parseFloat(newValue) || 0));
40
+ this.updateProgress();
41
+ }
42
+ }
43
+
44
+ connectedCallback() {
45
+ this.render();
46
+ }
47
+
48
+ render() {
49
+ this.shadowRoot.innerHTML = `
50
+ <slot></slot>
51
+ <svg
52
+ width="200"
53
+ height="200"
54
+ viewBox="-25 -25 250 250"
55
+ style="transform: rotate(-90deg)"
56
+ >
57
+ <!-- Background circle -->
58
+ <circle
59
+ r="${this.radius}"
60
+ cx="100"
61
+ cy="100"
62
+ fill="transparent"
63
+ stroke="#e0e0e0"
64
+ stroke-width="16px"
65
+ stroke-dasharray="${this.circumference}px"
66
+ stroke-dashoffset="${this.circumference}px"
67
+ ></circle>
68
+
69
+ <!-- Progress circle -->
70
+ <circle
71
+ id="progress-circle"
72
+ r="${this.radius}"
73
+ cx="100"
74
+ cy="100"
75
+ fill="transparent"
76
+ stroke="#6bdba7"
77
+ stroke-width="16px"
78
+ stroke-linecap="round"
79
+ stroke-dasharray="${this.circumference}px"
80
+ style="transition: stroke-dashoffset 0.1s ease-in-out"
81
+ ></circle>
82
+
83
+ <!-- Progress text -->
84
+ <text
85
+ id="progress-text"
86
+ x="44px"
87
+ y="115px"
88
+ fill="#6bdba7"
89
+ font-size="52px"
90
+ font-weight="bold"
91
+ style="transform:rotate(90deg) translate(0px, -196px)"
92
+ ></text>
93
+ </svg>
94
+ `;
95
+ }
96
+
97
+ updateProgress() {
98
+ if (!this.shadowRoot) return;
99
+
100
+ const progressCircle = this.shadowRoot.getElementById('progress-circle');
101
+ const progressText = this.shadowRoot.getElementById('progress-text');
102
+
103
+ if (progressCircle && progressText) {
104
+ // Calculate stroke-dashoffset based on progress
105
+ const offset = this.circumference - (this._progress / 100) * this.circumference;
106
+ progressCircle.style.strokeDashoffset = `${offset}px`;
107
+
108
+ // Update text
109
+ progressText.textContent = `${Math.round(this._progress)}%`;
110
+ }
111
+ }
112
+ }
113
+
114
+ // Register the custom element
115
+ customElements.define('circular-progress', CircularProgress);
116
+ JAVASCRIPT
117
+
118
+ # The initial index HTML page
119
+ INDEX = <<~HTML
120
+ <!DOCTYPE html>
121
+ <html>
122
+ <head>
123
+ <meta charset="UTF-8">
124
+ <title>Datastar progress-circle</title>
125
+ <style>
126
+ body {
127
+ font-family: Arial, sans-serif;
128
+ padding: 20px;
129
+ background-color: #f5f5f5;
130
+ }
131
+ .demo-container {
132
+ max-width: 800px;
133
+ margin: 0 auto;
134
+ background: white;
135
+ padding: 30px;
136
+ border-radius: 10px;
137
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
138
+ }
139
+ button {
140
+ background: linear-gradient(135deg, #6bdba7 0%, #5bc399 100%);
141
+ color: white;
142
+ border: none;
143
+ padding: 12px 24px;
144
+ font-size: 16px;
145
+ font-weight: 600;
146
+ border-radius: 8px;
147
+ cursor: pointer;
148
+ transition: all 0.2s ease;
149
+ box-shadow: 0 2px 4px rgba(107, 219, 167, 0.3);
150
+ margin-bottom: 20px;
151
+ }
152
+ button:hover:not([aria-disabled="true"]) {
153
+ background: linear-gradient(135deg, #5bc399 0%, #4db389 100%);
154
+ transform: translateY(-1px);
155
+ box-shadow: 0 4px 8px rgba(107, 219, 167, 0.4);
156
+ }
157
+ button:active:not([aria-disabled="true"]) {
158
+ transform: translateY(0);
159
+ box-shadow: 0 2px 4px rgba(107, 219, 167, 0.3);
160
+ }
161
+ button[aria-disabled="true"] {
162
+ background: #e0e0e0;
163
+ color: #999;
164
+ cursor: not-allowed;
165
+ box-shadow: none;
166
+ }
167
+ .col {
168
+ flex: 1;
169
+ padding: 0 15px;
170
+ min-height: 340px;
171
+ }
172
+ .col:first-child {
173
+ padding-left: 0;
174
+ display: flex;
175
+ flex-direction: column;
176
+ align-items: center;
177
+ justify-content: center;
178
+ }
179
+ .col:last-child {
180
+ padding-right: 0;
181
+ }
182
+ @media (min-width: 768px) {
183
+ .demo-container {
184
+ display: flex;
185
+ gap: 30px;
186
+ }
187
+ }
188
+ #activity {
189
+ overflow-y: auto;
190
+ border: 1px solid #e0e0e0;
191
+ border-radius: 8px;
192
+ padding: 16px;
193
+ background: #fafafa;
194
+ }
195
+ .a-item {
196
+ background: white;
197
+ border: 1px solid #e8e8e8;
198
+ border-radius: 6px;
199
+ padding: 12px 16px;
200
+ margin-bottom: 8px;
201
+ font-size: 14px;
202
+ color: #333;
203
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
204
+ transition: all 0.2s ease;
205
+ }
206
+ .a-item:last-child {
207
+ margin-bottom: 0;
208
+ }
209
+ .a-item:hover {
210
+ background: #f8f9fa;
211
+ border-color: #d0d0d0;
212
+ }
213
+ .a-item .time {
214
+ display: block;
215
+ font-size: 11px;
216
+ color: #888;
217
+ margin-bottom: 4px;
218
+ font-family: monospace;
219
+ }
220
+ .a-item.done {
221
+ background: #f0f9f4;
222
+ border-color: #6bdba7;
223
+ color: #2d5a3d;
224
+ }
225
+ .a-item.done .time {
226
+ color: #5a8a6a;
227
+ }
228
+ #title {
229
+ text-align: center;
230
+ }
231
+ </style>
232
+ <script type="module">#{PROGRESS}</script>
233
+ <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
234
+ </head>
235
+ <body>
236
+ <div class="demo-container">
237
+ <div class="col">
238
+ <p>
239
+ <button
240
+ data-indicator:_fetching
241
+ data-on:click="!$_fetching && @get('/', {openWhenHidden: true})"
242
+ data-attr:aria-disabled="`${$_fetching}`"
243
+ >Start</button>
244
+ </p>
245
+ <div id="work">
246
+ </div>
247
+ </div>
248
+
249
+ <div class="col" id="activity">
250
+ </div>
251
+ </div>
252
+ </body>
253
+ <html>
254
+ HTML
255
+
256
+ trap('INT') { exit }
257
+
258
+ # The server-side app
259
+ # It handles the initial page load and serves the initial HTML.
260
+ # It also handles Datastar SSE requests and streams updates to the client.
261
+ run do |env|
262
+ datastar = Datastar
263
+ .from_rack_env(env)
264
+ .on_connect do |socket|
265
+ p ['connect', socket]
266
+ end.on_server_disconnect do |socket|
267
+ p ['server disconnect', socket]
268
+ end.on_client_disconnect do |socket|
269
+ p ['client disconnect', socket]
270
+ end.on_error do |error|
271
+ p ['exception', error]
272
+ puts error.backtrace.join("\n")
273
+ end
274
+
275
+ if datastar.sse? # <= we're in a Datastar SSE request
276
+
277
+ # A thread to simulate the work and control the progress component
278
+ datastar.stream do |sse|
279
+ # Reset activity
280
+ sse.patch_elements(%(<div id="activity" class="col"></div>))
281
+
282
+ # step 1: add the initial progress component to the DOM
283
+ sse.patch_elements(%(<circular-progress id="work" data-bind:progress data-attr:progress="$progress"><h1 id="title">Processing...</h1></circular-progress>))
284
+
285
+ # step 2: simulate work and update the progress signal
286
+ 0.upto(100) do |i|
287
+ sleep rand(0.03..0.09) # Simulate work
288
+ sse.patch_signals(progress: i)
289
+ end
290
+
291
+ # step 3: update the DOM to indicate completion
292
+ # sse.patch_elements(%(<p id="work">Done!</p>))
293
+ sse.patch_elements(%(<div class="a-item done"><span class="time">#{Time.now.iso8601}</span>Done!</div>), selector: '#activity', mode: 'append')
294
+ sse.patch_elements(%(<h1 id="title">Done!</h1>))
295
+ end
296
+
297
+ # A second thread to push activity updates to the UI
298
+ datastar.stream do |sse|
299
+ ['Work started', 'Connecting to API', 'downloading data', 'processing data'].each do |activity|
300
+ sse.patch_elements(%(<div class="a-item"><span class="time">#{Time.now.iso8601}</span>#{activity}</div>), selector: '#activity', mode: 'append')
301
+ sleep rand(0.5..1.7) # Simulate time taken for each activity
302
+ end
303
+ end
304
+ else # <= We're in a regular HTTP request
305
+ [200, { 'content-type' => 'text/html' }, [INDEX]]
306
+ end
307
+ end
@@ -5,4 +5,4 @@ source 'https://rubygems.org'
5
5
  gem 'puma'
6
6
  gem 'rack'
7
7
  # gem 'datastar'
8
- gem 'datastar', path: '../../../sdk/ruby'
8
+ gem 'datastar', path: '../../../'
@@ -1,7 +1,7 @@
1
1
  PATH
2
- remote: ../../../sdk/ruby
2
+ remote: ../../..
3
3
  specs:
4
- datastar (1.0.0.beta.3)
4
+ datastar (1.0.0)
5
5
  json
6
6
  logger
7
7
  rack (>= 3.1.14)
@@ -9,7 +9,7 @@ PATH
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- json (2.12.2)
12
+ json (2.16.0)
13
13
  logger (1.7.0)
14
14
  nio4r (2.7.4)
15
15
  puma (6.6.0)
@@ -26,12 +26,12 @@ INDEX = <<~HTML
26
26
  span { font-weight: bold; }
27
27
  }
28
28
  </style>
29
- <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@release-candidate/bundles/datastar.js"></script>
29
+ <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
30
30
  </head>
31
31
  <body>
32
32
  <button#{' '}
33
- data-on-click="@get('/')"#{' '}
34
- data-indicator-heartbeat#{' '}
33
+ data-on:click="@get('/')"#{' '}
34
+ data-indicator:heartbeat#{' '}
35
35
  >Start</button>
36
36
  <p class="counter">Slow thread: <span id="slow">waiting</span></p>
37
37
  <p class="counter">Fast thread: <span id="fast">waiting</span></p>
@@ -22,7 +22,9 @@ module Datastar
22
22
 
23
23
  def new_queue = Async::Queue.new
24
24
 
25
- def prepare(response); end
25
+ def prepare(response)
26
+ response.delete_header 'Connection'
27
+ end
26
28
 
27
29
  def spawn(&block)
28
30
  Async(&block)
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Datastar
6
+ module Compressor
7
+ # Null compressor — no-op, used when compression is disabled or no match.
8
+ class Null
9
+ def encoding = nil
10
+ def wrap_socket(socket) = socket
11
+ def prepare_response(_response) = nil
12
+ end
13
+
14
+ NONE = Null.new.freeze
15
+ end
16
+
17
+ # Immutable value object that holds an ordered list of pre-built compressors
18
+ # and negotiates the best one for a given request.
19
+ #
20
+ # Use {.build} to create instances from user-facing configuration values.
21
+ # The first compressor in the list is preferred when the client supports multiple.
22
+ #
23
+ # @example Via global configuration
24
+ # Datastar.configure do |config|
25
+ # config.compression = true # [:br, :gzip] with default options
26
+ # config.compression = [:br, :gzip] # preferred = first in list
27
+ # config.compression = [[:br, { quality: 5 }], :gzip] # per-encoder options
28
+ # end
29
+ #
30
+ # @example Per-request negotiation (used internally by Dispatcher)
31
+ # compressor = Datastar.config.compression.negotiate(request)
32
+ # compressor.prepare_response(response)
33
+ # socket = compressor.wrap_socket(raw_socket)
34
+ class CompressionConfig
35
+ ACCEPT_ENCODING = 'HTTP_ACCEPT_ENCODING'
36
+ BLANK_HASH = {}.freeze
37
+
38
+ # Build a {CompressionConfig} from various user-facing input forms.
39
+ #
40
+ # @param input [Boolean, Array<Symbol, Array(Symbol, Hash)>, CompressionConfig]
41
+ # - +false+ / +nil+ — compression disabled (empty compressor list)
42
+ # - +true+ — enable +:br+ and +:gzip+ with default options
43
+ # - +Array<Symbol>+ — enable listed encodings with default options, e.g. +[:gzip]+
44
+ # - +Array<Array(Symbol, Hash)>+ — enable with per-encoder options,
45
+ # e.g. +[[:br, { quality: 5 }], :gzip]+
46
+ # - +CompressionConfig+ — returned as-is
47
+ # @return [CompressionConfig]
48
+ # @raise [ArgumentError] if +input+ is not a recognised form
49
+ # @raise [LoadError] if a requested encoder's gem is not available (e.g. +brotli+)
50
+ #
51
+ # @example Disable compression
52
+ # CompressionConfig.build(false)
53
+ #
54
+ # @example Enable all supported encodings
55
+ # CompressionConfig.build(true)
56
+ #
57
+ # @example Gzip only, with custom level
58
+ # CompressionConfig.build([[:gzip, { level: 1 }]])
59
+ def self.build(input)
60
+ case input
61
+ when CompressionConfig
62
+ input
63
+ when false, nil
64
+ new([])
65
+ when true
66
+ new([build_compressor(:br), build_compressor(:gzip)])
67
+ when Array
68
+ compressors = input.map do |entry|
69
+ case entry
70
+ when Symbol
71
+ build_compressor(entry)
72
+ when Array
73
+ name, options = entry
74
+ build_compressor(name, options || BLANK_HASH)
75
+ else
76
+ raise ArgumentError, "Invalid compression entry: #{entry.inspect}. Expected Symbol or [Symbol, Hash]."
77
+ end
78
+ end
79
+ new(compressors)
80
+ else
81
+ raise ArgumentError, "Invalid compression value: #{input.inspect}. Expected true, false, or Array."
82
+ end
83
+ end
84
+
85
+ def self.build_compressor(name, options = BLANK_HASH)
86
+ case name
87
+ when :br
88
+ require_relative 'compressor/brotli'
89
+ Compressor::Brotli.new(options)
90
+ when :gzip
91
+ require_relative 'compressor/gzip'
92
+ Compressor::Gzip.new(options)
93
+ else
94
+ raise ArgumentError, "Unknown compressor: #{name.inspect}. Expected :br or :gzip."
95
+ end
96
+ end
97
+ private_class_method :build_compressor
98
+
99
+ # @param compressors [Array<Compressor::Gzip, Compressor::Brotli>]
100
+ # ordered list of pre-built compressor instances. First = preferred.
101
+ def initialize(compressors)
102
+ @compressors = compressors.freeze
103
+ freeze
104
+ end
105
+
106
+ # Whether any compressors are configured.
107
+ #
108
+ # @return [Boolean]
109
+ #
110
+ # @example
111
+ # CompressionConfig.build(false).enabled? # => false
112
+ # CompressionConfig.build(true).enabled? # => true
113
+ def enabled?
114
+ @compressors.any?
115
+ end
116
+
117
+ # Negotiate compression with the client based on the +Accept-Encoding+ header.
118
+ #
119
+ # Iterates the configured compressors in order (first = preferred) and returns
120
+ # the first one whose encoding the client accepts. Returns {Compressor::NONE}
121
+ # when compression is disabled, the header is absent, or no match is found.
122
+ #
123
+ # No objects are created per-request — compressors are pre-built and reused.
124
+ #
125
+ # @param request [Rack::Request]
126
+ # @return [Compressor::Gzip, Compressor::Brotli, Compressor::Null]
127
+ #
128
+ # @example
129
+ # config = CompressionConfig.build([:gzip, :br])
130
+ # compressor = config.negotiate(request)
131
+ # compressor.prepare_response(response)
132
+ # socket = compressor.wrap_socket(raw_socket)
133
+ def negotiate(request)
134
+ return Compressor::NONE unless enabled?
135
+
136
+ accepted = parse_accept_encoding(request.get_header(ACCEPT_ENCODING).to_s)
137
+ return Compressor::NONE if accepted.empty?
138
+
139
+ @compressors.each do |compressor|
140
+ return compressor if accepted.include?(compressor.encoding)
141
+ end
142
+
143
+ Compressor::NONE
144
+ end
145
+
146
+ private
147
+
148
+ # Parse Accept-Encoding header into a set of encoding symbols
149
+ # @param header [String]
150
+ # @return [Set<Symbol>]
151
+ def parse_accept_encoding(header)
152
+ return Set.new if header.empty?
153
+
154
+ encodings = Set.new
155
+ header.split(',').each do |part|
156
+ encoding, quality = part.strip.split(';', 2)
157
+ encoding = encoding.strip.downcase
158
+ if quality
159
+ q_val = quality.strip.match(/q=(\d+\.?\d*)/)
160
+ next if q_val && q_val[1].to_f == 0
161
+ end
162
+ encodings << encoding.to_sym
163
+ end
164
+ encodings
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'brotli'
4
+
5
+ module Datastar
6
+ module Compressor
7
+ # Brotli compressor — built once at config time, reused across requests.
8
+ # Eagerly requires the brotli gem; raises LoadError at boot if missing.
9
+ class Brotli
10
+ attr_reader :encoding
11
+
12
+ def initialize(options)
13
+ @options = options.freeze
14
+ @encoding = :br
15
+ freeze
16
+ end
17
+
18
+ def prepare_response(response)
19
+ response.headers['Content-Encoding'] = 'br'
20
+ response.headers['Vary'] = 'Accept-Encoding'
21
+ end
22
+
23
+ def wrap_socket(socket)
24
+ CompressedSocket.new(socket, @options)
25
+ end
26
+
27
+ # Brotli compressed socket using the `brotli` gem.
28
+ # Options are passed directly to Brotli::Compressor.new:
29
+ # :quality - Compression quality (0-11, default: 11). Lower is faster, higher compresses better.
30
+ # :lgwin - Base-2 log of the sliding window size (10-24, default: 22).
31
+ # :lgblock - Base-2 log of the maximum input block size (16-24, 0 = auto, default: 0).
32
+ # :mode - Compression mode (:generic, :text, or :font, default: :generic).
33
+ # Use :text for UTF-8 formatted text (HTML, JSON — good for SSE).
34
+ class CompressedSocket
35
+ def initialize(socket, options = {})
36
+ @socket = socket
37
+ @compressor = ::Brotli::Compressor.new(options)
38
+ end
39
+
40
+ def <<(data)
41
+ compressed = @compressor.process(data)
42
+ @socket << compressed if compressed && !compressed.empty?
43
+ flushed = @compressor.flush
44
+ @socket << flushed if flushed && !flushed.empty?
45
+ self
46
+ end
47
+
48
+ def close
49
+ final = @compressor.finish
50
+ @socket << final if final && !final.empty?
51
+ @socket.close
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Datastar
6
+ module Compressor
7
+ # Gzip compressor — built once at config time, reused across requests.
8
+ class Gzip
9
+ attr_reader :encoding
10
+
11
+ def initialize(options)
12
+ @options = options.freeze
13
+ @encoding = :gzip
14
+ freeze
15
+ end
16
+
17
+ def prepare_response(response)
18
+ response.headers['Content-Encoding'] = 'gzip'
19
+ response.headers['Vary'] = 'Accept-Encoding'
20
+ end
21
+
22
+ def wrap_socket(socket)
23
+ CompressedSocket.new(socket, @options)
24
+ end
25
+
26
+ # Gzip compressed socket using Ruby's built-in zlib.
27
+ # Options:
28
+ # :level - Compression level (0-9, default: Zlib::DEFAULT_COMPRESSION).
29
+ # 0 = no compression, 1 = best speed, 9 = best compression.
30
+ # Zlib::BEST_SPEED (1) and Zlib::BEST_COMPRESSION (9) also work.
31
+ # :mem_level - Memory usage level (1-9, default: 8). Higher uses more memory for better compression.
32
+ # :strategy - Compression strategy (default: Zlib::DEFAULT_STRATEGY).
33
+ # Zlib::FILTERED, Zlib::HUFFMAN_ONLY, Zlib::RLE, Zlib::FIXED are also available.
34
+ class CompressedSocket
35
+ def initialize(socket, options = {})
36
+ level = options.fetch(:level, Zlib::DEFAULT_COMPRESSION)
37
+ mem_level = options.fetch(:mem_level, Zlib::DEF_MEM_LEVEL)
38
+ strategy = options.fetch(:strategy, Zlib::DEFAULT_STRATEGY)
39
+ # Use raw deflate with gzip wrapping (window_bits 31 = 15 + 16)
40
+ @socket = socket
41
+ @deflate = Zlib::Deflate.new(level, 31, mem_level, strategy)
42
+ end
43
+
44
+ def <<(data)
45
+ compressed = @deflate.deflate(data, Zlib::SYNC_FLUSH)
46
+ @socket << compressed if compressed && !compressed.empty?
47
+ self
48
+ end
49
+
50
+ def close
51
+ final = @deflate.finish
52
+ @socket << final if final && !final.empty?
53
+ @socket.close
54
+ ensure
55
+ @deflate.close
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -35,6 +35,7 @@ module Datastar
35
35
  DEFAULT_HEARTBEAT = 3
36
36
 
37
37
  attr_accessor :executor, :error_callback, :finalize, :heartbeat, :logger
38
+ attr_reader :compression
38
39
 
39
40
  def initialize
40
41
  @executor = ThreadExecutor.new
@@ -44,6 +45,11 @@ module Datastar
44
45
  @error_callback = proc do |e|
45
46
  @logger.error("#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
46
47
  end
48
+ @compression = CompressionConfig.build(false)
49
+ end
50
+
51
+ def compression=(value)
52
+ @compression = value.is_a?(CompressionConfig) ? value : CompressionConfig.build(value)
47
53
  end
48
54
 
49
55
  def on_error(callable = nil, &block)