shipeasy-sdk 1.6.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73c3a9cf2fd1a6fb0a223b9db5bcbe13cd828bfd96a4e901de545ff56d254ce7
4
- data.tar.gz: '0489c3e16d16592b1301e888c82fa1d5947c21c08c74adbd9eacb6db86420138'
3
+ metadata.gz: 97efde389fa7a5adc1ffdab3951d09837f435bd529d8f6c3b7e3d45387472628
4
+ data.tar.gz: 46f183776d94ccbddd8781e39d3ea5e56e6795cf26f3292f1482c2886b7d0836
5
5
  SHA512:
6
- metadata.gz: 9f2a91c5482d5eb3baf71e744116b7295ad50b19cea9a96c2ce6b6faa01467a38ca4db91f449ae773de76144156eaeec14052d14d12f3c68a3dc521fa1c6813b
7
- data.tar.gz: b8e2512bbc85e404fa4528c411700bff22e7eea40f20be2d6392bd7f59166eb65464cfe17760f8b7a2f56c0eb2c7ca15a42fbc579c0937f04ce61c402824a862
6
+ metadata.gz: 010654d1322131ab21b467fdff8176914e427558a5a620762c4417378f68c5cc5f3b189e7b0cdb8de6b741d2232e7c1552b8fd551bc3fce19b09e91263e18d12
7
+ data.tar.gz: 24730826e8f2de0ac3f1546ab4f42b2301bcd8f83bdaea0b9c50487d7b90d5b445470827199de73c4794bc16b2ddeed3e18fc44079c2000cd0c92aeb07f50e55
data/README.md CHANGED
@@ -14,34 +14,56 @@ gem "shipeasy-sdk"
14
14
 
15
15
  ## Quickstart (Rails)
16
16
 
17
- `config/initializers/shipeasy.rb` is all you need:
17
+ Two parts: **configure once** at boot, then build a **user-bound
18
+ `Shipeasy::Client`** per request via its constructor.
19
+
20
+ `config/initializers/shipeasy.rb`:
18
21
 
19
22
  ```ruby
20
23
  Shipeasy.configure do |c|
21
24
  c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
25
+
26
+ # Optional: map YOUR user object → the Shipeasy attribute hash. Runs once,
27
+ # in the Shipeasy::Client constructor. Omit it and the object you pass to
28
+ # Shipeasy::Client.new IS the attribute hash (identity).
29
+ c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
30
+
22
31
  c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
23
32
  c.profile = "default"
24
33
  end
25
34
  ```
26
35
 
27
- Anywhere in your app:
36
+ `configure` builds the single global engine for you and kicks off a one-shot
37
+ fetch (fire-and-forget). Anywhere in your app, construct a bound client and call
38
+ the getters with **no user argument** — the user is bound at construction:
28
39
 
29
40
  ```ruby
30
- user = { user_id: current_user.id, plan: current_user.plan }
41
+ flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
31
42
 
32
- if Shipeasy.flags.get_flag("new_checkout", user)
43
+ if flags.get_flag("new_checkout") # NO user arg
33
44
  # ship it
34
45
  end
35
46
 
36
- color = Shipeasy.flags.get_config("button_color")
37
- result = Shipeasy.flags.get_experiment("checkout_cta", user, { label: "Buy now" })
38
- Shipeasy.flags.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
47
+ color = flags.get_config("button_color")
48
+ result = flags.get_experiment("checkout_cta", { label: "Buy now" })
49
+ panic = flags.get_killswitch("payments")
39
50
  ```
40
51
 
41
- `Shipeasy.flags` is a lazy, **fork-safe** singleton: the first call from
42
- each process spawns its own `FlagsClient` and starts the background poll
43
- thread, including post-fork Puma workers under `preload_app!`. No need
44
- for `before_worker_boot` hooks or holding a global constant.
52
+ `Shipeasy::Client` is **cheap**: it delegates evaluation to the single engine
53
+ built by `configure` — it never opens its own connection, fetches, or polls.
54
+ Construct one per user / per request.
55
+
56
+ Event ingestion (`track`) lives on the engine — `Shipeasy.engine` is the global
57
+ one `configure` registered:
58
+
59
+ ```ruby
60
+ Shipeasy.engine.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
61
+ ```
62
+
63
+ > **Upgrading from 1.x?** The heavyweight client was renamed
64
+ > `Shipeasy::SDK::FlagsClient` → `Shipeasy::Engine`, and `Shipeasy::Client` is
65
+ > now the lightweight user-bound handle. The legacy `Shipeasy.flags.get_flag(name, user)`
66
+ > singleton still works.
45
67
 
46
68
  In a Rails view (the railtie auto-mounts these helpers when Rails is loaded):
47
69
 
@@ -61,7 +83,7 @@ wiring** — `get_flag` on an anonymous request just works:
61
83
 
62
84
  ```ruby
63
85
  # current_user is nil → buckets on the __se_anon_id cookie automatically
64
- Shipeasy.flags.get_flag("new_checkout", {})
86
+ Shipeasy::Client.new({}).get_flag("new_checkout")
65
87
  ```
66
88
 
67
89
  An explicit `user_id` / `anonymous_id` always wins. If you prefer to read the id
@@ -86,7 +108,8 @@ require "shipeasy-sdk"
86
108
 
87
109
  Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
88
110
 
89
- Shipeasy.flags.get_flag("new_checkout", { user_id: "u_1" })
111
+ # With no `attributes` transform, the hash you pass IS the attribute map.
112
+ Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout")
90
113
  ```
91
114
 
92
115
  The Rails view helpers (`i18n_*`) are not loaded outside Rails, so the
@@ -99,9 +122,9 @@ short-lived function. Build the client explicitly and call `init_once`
99
122
  for a single synchronous fetch:
100
123
 
101
124
  ```ruby
102
- client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
103
- client.init_once
104
- client.get_flag("new_checkout", user)
125
+ engine = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
126
+ engine.init_once
127
+ engine.get_flag("new_checkout", user)
105
128
  ```
106
129
 
107
130
  ## Lifecycle escape hatch
@@ -115,6 +138,31 @@ client.init
115
138
  at_exit { client.destroy }
116
139
  ```
117
140
 
141
+ ## Server-side rendering (SSR)
142
+
143
+ Emit the request's evaluated flags as a declarative `<script>` tag so the
144
+ browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
145
+ in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
146
+ hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
147
+ browser buckets identically to the server.
148
+
149
+ ```ruby
150
+ user = { "user_id" => "u_123" }
151
+
152
+ # Two tags for the document <head>. The PUBLIC client key (not the server
153
+ # key) goes on the i18n loader tag.
154
+ head = client.bootstrap_script_tag(user, anon_id: anon_id) +
155
+ client.i18n_script_tag(client_key, profile: "en:prod")
156
+
157
+ # …or get the raw payload ({ "flags", "configs", "experiments", "killswitches" }):
158
+ boot = client.evaluate(user)
159
+ ```
160
+
161
+ `bootstrap_script_tag` also accepts `i18n_profile:` and `base_url:` (defaults to
162
+ `https://cdn.shipeasy.ai`). In **Rails**, the existing
163
+ `Shipeasy::I18n::ViewHelpers#i18n_script_tag` view helper still renders the i18n
164
+ loader tag from your app config.
165
+
118
166
  ## Default values
119
167
 
120
168
  `get_flag` and `get_config` take an optional `default:` returned **only when the
@@ -181,9 +229,9 @@ endpoints:
181
229
  ```
182
230
 
183
231
  ```ruby
184
- client = Shipeasy::SDK::FlagsClient.from_file("snapshot.json")
232
+ client = Shipeasy::Engine.from_file("snapshot.json")
185
233
  # or, from already-parsed blobs:
186
- client = Shipeasy::SDK::FlagsClient.from_snapshot(flags: flags_body, experiments: exps_body)
234
+ client = Shipeasy::Engine.from_snapshot(flags: flags_body, experiments: exps_body)
187
235
 
188
236
  client.get_flag("new_checkout", user) # real evaluation, no network
189
237
  client.get_experiment("checkout_cta", user, {})
@@ -210,7 +258,7 @@ snapshot.
210
258
 
211
259
  For unit/integration tests you want a client that does **zero network** and
212
260
  returns exactly the values you seed — no api_key, no fetch, no poll thread, no
213
- telemetry, no metric ingestion. Build one with `FlagsClient.for_testing` and
261
+ telemetry, no metric ingestion. Build one with `Shipeasy::Engine.for_testing` and
214
262
  seed each entity with the `override_*` setters (Statsig-style local overrides).
215
263
  An override always wins over the fetched blob, so the getters answer
216
264
  deterministically:
@@ -218,7 +266,7 @@ deterministically:
218
266
  ```ruby
219
267
  require "shipeasy-sdk"
220
268
 
221
- client = Shipeasy::SDK::FlagsClient.for_testing
269
+ client = Shipeasy::Engine.for_testing
222
270
  # init / init_once are no-ops here — nothing is ever fetched.
223
271
 
224
272
  # Flags (boolean)
@@ -247,31 +295,35 @@ client.clear_overrides
247
295
 
248
296
  The same `override_flag` / `override_config` / `override_experiment` /
249
297
  `clear_overrides` setters also work on a **normal** live client (built with
250
- `FlagsClient.new(...)`), so you can pin one value in local development while the
298
+ `Shipeasy::Engine.new(...)`), so you can pin one value in local development while the
251
299
  rest comes from the fetched blob.
252
300
 
253
- ### Rails singleton
301
+ ### Global engine / bound client
254
302
 
255
- `Shipeasy.flags` is a process-wide singleton that fetches over the network, so
256
- in tests prefer a `for_testing` client. If a code path reaches through
257
- `Shipeasy.flags` directly, stub the singleton to the test client in your test
258
- setup:
303
+ `Shipeasy.engine` (registered by `configure`) and `Shipeasy.flags` (legacy
304
+ singleton) both fetch over the network, so in tests stub them to a
305
+ `for_testing` engine. `Shipeasy::Client.new(user)` reads `Shipeasy.engine`, so
306
+ stubbing the engine also covers the bound-client path:
259
307
 
260
308
  ```ruby
261
309
  # RSpec
262
310
  before do
263
- test_client = Shipeasy::SDK::FlagsClient.for_testing
264
- test_client.override_flag("new_checkout", true)
265
- allow(Shipeasy).to receive(:flags).and_return(test_client)
311
+ test_engine = Shipeasy::Engine.for_testing
312
+ test_engine.override_flag("new_checkout", true)
313
+ allow(Shipeasy).to receive(:engine).and_return(test_engine)
314
+ allow(Shipeasy).to receive(:flags).and_return(test_engine) # legacy path
315
+
316
+ # Shipeasy::Client.new(user).get_flag("new_checkout") now => true
266
317
  end
267
318
  ```
268
319
 
269
320
  ## Configuration
270
321
 
271
- | Parameter | Default | Description |
272
- | ---------- | ------------------------- | ----------------------------------- |
273
- | `api_key` | (required) | SDK key from the Shipeasy dashboard |
274
- | `base_url` | `https://cdn.shipeasy.ai` | Override for local dev / staging |
322
+ | Parameter | Default | Description |
323
+ | ------------ | ----------------------------- | ------------------------------------------------------------------- |
324
+ | `api_key` | (required) | SDK key from the Shipeasy dashboard |
325
+ | `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
326
+ | `attributes` | identity (`->(u) { u }`) | Callable mapping your user object → the Shipeasy attribute hash |
275
327
 
276
328
  ## Documentation
277
329
 
@@ -0,0 +1,62 @@
1
+ module Shipeasy
2
+ # A lightweight, user-bound evaluation handle. Construct one per user/request
3
+ # via its real constructor:
4
+ #
5
+ # flags = Shipeasy::Client.new(current_user)
6
+ # flags.get_flag("new_checkout") # NO user arg — bound at construction
7
+ # flags.get_experiment("price_test", { price: 9 })
8
+ #
9
+ # It is cheap: it delegates every evaluation to the single global engine built
10
+ # by `Shipeasy.configure { … }`. It does NOT open its own HTTP connection,
11
+ # fetch, or start a poll timer.
12
+ #
13
+ # The configured `attributes` transform (see Shipeasy::Configuration#attributes)
14
+ # runs ONCE here, in the constructor, against the raw user object you pass.
15
+ # The resulting attribute hash is then enriched with the request-scoped
16
+ # anonymous_id (when you supplied neither user_id nor anonymous_id) and bound,
17
+ # so every getter reads the same bag.
18
+ #
19
+ # Raises if constructed before `Shipeasy.configure` registered an engine.
20
+ class Client
21
+ # The resolved attribute hash this handle evaluates against.
22
+ attr_reader :attributes
23
+
24
+ def initialize(user)
25
+ engine = Shipeasy.engine
26
+ if engine.nil?
27
+ raise Error, "Shipeasy::Client.new(user) called before Shipeasy.configure " \
28
+ "{ |c| c.api_key = … }. Call Shipeasy.configure once at app boot."
29
+ end
30
+ @engine = engine
31
+ # Run the configured attributes transform (default identity), then apply
32
+ # the existing anon-id merge exactly as the per-call engine path does.
33
+ mapped = Shipeasy.attributes_transform.call(user)
34
+ @attributes = engine.bind_attributes(mapped)
35
+ end
36
+
37
+ def get_flag(name, default: false)
38
+ @engine.get_flag(name, @attributes, default: default)
39
+ end
40
+
41
+ def get_flag_detail(name)
42
+ @engine.get_flag_detail(name, @attributes)
43
+ end
44
+
45
+ # Configs are not user-scoped, but exposed here for one-stop ergonomics.
46
+ def get_config(name, decode = nil, default: nil)
47
+ @engine.get_config(name, decode, default: default)
48
+ end
49
+
50
+ def get_experiment(name, default_params, decode = nil)
51
+ @engine.get_experiment(name, @attributes, default_params, decode)
52
+ end
53
+
54
+ # Killswitches are not user-scoped; forwarded straight to the engine.
55
+ def get_killswitch(name, switch_key = nil)
56
+ @engine.get_killswitch(name, switch_key)
57
+ end
58
+ end
59
+
60
+ # Raised by Shipeasy::Client when constructed before Shipeasy.configure.
61
+ class Error < StandardError; end
62
+ end
@@ -1,7 +1,7 @@
1
1
  # Single configuration object for the Shipeasy gem.
2
2
  #
3
3
  # Covers both subsystems:
4
- # - SDK / experimentation (api_key, base_url) — drives FlagsClient
4
+ # - SDK / experimentation (api_key, base_url) — drives Engine
5
5
  # - i18n / string manager (public_key, profile, cdn_base_url, ...) — drives
6
6
  # the Rails view helpers and label fetcher
7
7
  #
@@ -14,7 +14,7 @@
14
14
  # end
15
15
  #
16
16
  # Anything not set falls back to the defaults below. The same Shipeasy.config
17
- # is read by FlagsClient and the Rails helpers, so there is one place to
17
+ # is read by Engine and the Rails helpers, so there is one place to
18
18
  # point environment variables at.
19
19
 
20
20
  module Shipeasy
@@ -22,6 +22,18 @@ module Shipeasy
22
22
  # ---- experimentation / SDK ----
23
23
  attr_accessor :api_key, :base_url
24
24
 
25
+ # Optional transform from YOUR user object (any shape) to the Shipeasy
26
+ # attribute hash every flag/experiment evaluation uses. A callable
27
+ # (lambda/proc or anything responding to #call). Default = identity (the
28
+ # user object is assumed to already BE the attribute hash). Runs once, in
29
+ # the Shipeasy::Client constructor.
30
+ #
31
+ # Shipeasy.configure do |c|
32
+ # c.api_key = ENV["SHIPEASY_SERVER_KEY"]
33
+ # c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
34
+ # end
35
+ attr_accessor :attributes
36
+
25
37
  # ---- i18n / string manager ----
26
38
  attr_accessor :public_key, :profile, :default_chunk,
27
39
  :cdn_base_url, :loader_url,
@@ -29,6 +41,7 @@ module Shipeasy
29
41
 
30
42
  def initialize
31
43
  @base_url = "https://edge.shipeasy.dev"
44
+ @attributes = nil
32
45
 
33
46
  @profile = "default"
34
47
  @default_chunk = "index"
@@ -45,8 +58,68 @@ module Shipeasy
45
58
  @config ||= Configuration.new
46
59
  end
47
60
 
61
+ # Configure the gem once at boot. In addition to populating the shared
62
+ # Configuration, this builds and registers the ONE global Shipeasy::Engine
63
+ # (first-config-wins) from the api_key/base_url and kicks off its one-shot
64
+ # fetch (fire-and-forget) so `Shipeasy::Client.new(user).get_flag(...)`
65
+ # resolves against real rules with no explicit init call.
66
+ #
67
+ # Shipeasy.configure do |c|
68
+ # c.api_key = ENV["SHIPEASY_SERVER_KEY"]
69
+ # c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
70
+ # end
71
+ #
72
+ # Shipeasy::Client.new(current_user).get_flag("new_checkout")
73
+ #
74
+ # Long-running servers that also want the background poll can call
75
+ # `Shipeasy.engine.init` after configure.
48
76
  def configure
49
77
  yield config
78
+ register_engine!(config) if config.api_key
79
+ config
80
+ end
81
+
82
+ # The resolved attributes transform (callable). Default = identity, so a
83
+ # user object that is already the attribute hash is used verbatim.
84
+ def attributes_transform
85
+ transform = config.attributes
86
+ if transform.nil?
87
+ ->(user) { user }
88
+ elsif transform.respond_to?(:call)
89
+ transform
90
+ else
91
+ raise Error, "Shipeasy.configure { |c| c.attributes = … } must be a callable (e.g. a lambda)"
92
+ end
93
+ end
94
+
95
+ # The single global engine registered by configure, or nil if configure has
96
+ # not run (or ran without an api_key). Shipeasy::Client reads this.
97
+ def engine
98
+ pid = Process.pid
99
+ if @engine && @engine_pid != pid
100
+ # Post-fork: the parent's poll thread didn't survive. Rebuild lazily
101
+ # from the stored config in this child process.
102
+ @engine = nil
103
+ register_engine!(config) if config.api_key
104
+ end
105
+ @engine
106
+ end
107
+
108
+ # Build + register the one global engine (first-config-wins). Fires the
109
+ # one-shot fetch fire-and-forget. Idempotent within a process.
110
+ def register_engine!(cfg)
111
+ return @engine if @engine && @engine_pid == Process.pid
112
+ @engine_pid = Process.pid
113
+ engine = Engine.new(api_key: cfg.api_key, base_url: cfg.base_url)
114
+ @engine = engine
115
+ # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
116
+ # reset/reconfigure could nil out before the thread runs).
117
+ Thread.new do
118
+ engine.init_once
119
+ rescue => e
120
+ warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
121
+ end
122
+ engine
50
123
  end
51
124
 
52
125
  # Reset the config back to defaults — primarily for tests.
@@ -55,9 +128,12 @@ module Shipeasy
55
128
  @flags_pid = nil
56
129
  @flags&.destroy
57
130
  @flags = nil
131
+ @engine&.destroy
132
+ @engine = nil
133
+ @engine_pid = nil
58
134
  end
59
135
 
60
- # Lazy, fork-safe singleton FlagsClient. The first call from each
136
+ # Lazy, fork-safe singleton Engine. The first call from each
61
137
  # process spawns a fresh client + poll thread — including post-fork
62
138
  # workers under Puma's preload_app!. Callers can `Shipeasy.flags.get_flag(...)`
63
139
  # straight from a controller without holding a constant or worrying
@@ -70,7 +146,12 @@ module Shipeasy
70
146
  #
71
147
  # The first request that touches `Shipeasy.flags.*` triggers init().
72
148
  # For serverless / Lambda where you want a single fetch with no thread,
73
- # build the client explicitly: `Shipeasy::SDK::FlagsClient.new(...).init_once`.
149
+ # build the engine explicitly: `Shipeasy::Engine.new(...).init_once`.
150
+ #
151
+ # NOTE: this remains a separate, polling engine from the one configure()
152
+ # registers (Shipeasy.engine). New code should prefer the
153
+ # Shipeasy.configure + Shipeasy::Client.new(user) front door; `Shipeasy.flags`
154
+ # is retained for the legacy `Shipeasy.flags.get_flag(name, user)` style.
74
155
  def flags
75
156
  pid = Process.pid
76
157
  if @flags && @flags_pid != pid
@@ -81,7 +162,7 @@ module Shipeasy
81
162
  end
82
163
  @flags ||= begin
83
164
  @flags_pid = pid
84
- client = SDK::FlagsClient.new(
165
+ client = Engine.new(
85
166
  api_key: config.api_key,
86
167
  base_url: config.base_url,
87
168
  )
@@ -2,16 +2,36 @@ require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
4
  require "thread"
5
- require_relative "eval"
6
- require_relative "telemetry"
7
- require_relative "anon_id"
8
- require_relative "sticky_store"
9
- require_relative "see"
5
+ require "cgi"
6
+ require_relative "sdk/eval"
7
+ require_relative "sdk/telemetry"
8
+ require_relative "sdk/anon_id"
9
+ require_relative "sdk/sticky_store"
10
+ require_relative "sdk/see"
10
11
 
11
12
  module Shipeasy
12
- module SDK
13
- class FlagsClient
13
+ # The heavyweight engine: owns the api key, HTTP transport, the blob cache,
14
+ # the background poll timer, init/init_once, local overrides, track, and
15
+ # see()/default-client wiring. Was `Shipeasy::SDK::FlagsClient` before 2.0;
16
+ # renamed to a clean top-level `Shipeasy::Engine` when the lightweight
17
+ # user-bound `Shipeasy::Client` became the primary front door.
18
+ #
19
+ # Most apps never construct an Engine directly — `Shipeasy.configure { … }`
20
+ # builds and registers the one global engine for you. Construct one explicitly
21
+ # only for advanced/serverless flows (multiple keys, offline snapshots).
22
+ class Engine
23
+ # Internal collaborators still live under Shipeasy::SDK; alias them so the
24
+ # body below can keep referring to them unqualified after the class moved
25
+ # out from under the SDK namespace.
26
+ Eval = Shipeasy::SDK::Eval
27
+ Telemetry = Shipeasy::SDK::Telemetry
28
+ AnonId = Shipeasy::SDK::AnonId
29
+ See = Shipeasy::SDK::See
30
+
14
31
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
32
+ # CDN origin serving the static loader scripts (/sdk/bootstrap.js,
33
+ # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
34
+ DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
15
35
 
16
36
  def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil)
17
37
  @api_key = api_key
@@ -30,7 +50,7 @@ module Shipeasy
30
50
  @sticky_store = sticky_store
31
51
  # Test mode: no network, ever. init/init_once/track become no-ops and
32
52
  # evaluation answers come purely from local overrides. Built via the
33
- # FlagsClient.for_testing factory; see clear_overrides / override_*.
53
+ # Engine.for_testing factory; see clear_overrides / override_*.
34
54
  @test_mode = test_mode
35
55
  # Per-evaluation usage telemetry. ON by default; pass
36
56
  # disable_telemetry: true to opt out. See telemetry.rb.
@@ -271,6 +291,93 @@ module Shipeasy
271
291
  result
272
292
  end
273
293
 
294
+ # Public hook for the bound Shipeasy::Client: normalise an attribute hash
295
+ # and apply the request-scoped anonymous_id merge ONCE, at Client
296
+ # construction, exactly as every per-call getter does internally.
297
+ def bind_attributes(user)
298
+ with_anon_id(user)
299
+ end
300
+
301
+ # Read a killswitch from the cached flags blob. Without +switch_key+,
302
+ # returns true when the whole killswitch is killed. With +switch_key+,
303
+ # returns true when that specific per-key switch is on. Unknown
304
+ # killswitches / switches return false. Not user-scoped.
305
+ def get_killswitch(name, switch_key = nil)
306
+ @telemetry.emit("ks", name)
307
+ ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
308
+ return false unless ks
309
+ if switch_key.nil?
310
+ Eval.enabled?(ks["killed"])
311
+ else
312
+ Eval.enabled?(ks.dig("switches", switch_key.to_s))
313
+ end
314
+ end
315
+
316
+ # Batch-evaluate every loaded gate, config and experiment for +user+ into
317
+ # a bootstrap payload (+{ "flags" => ..., "configs" => ..., "experiments"
318
+ # => ..., "killswitches" => ... }+) keyed to match the browser SDK's
319
+ # window.__SE_BOOTSTRAP shape. Local overrides win. Killswitches are folded
320
+ # into per-gate evaluation, so the standalone +killswitches+ map is empty
321
+ # for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
322
+ def evaluate(user)
323
+ u = with_anon_id(user)
324
+ flags_blob, exps_blob, flag_ov, config_ov, exp_ov, sticky = @mutex.synchronize do
325
+ [@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup,
326
+ @exp_overrides.dup, @sticky_store]
327
+ end
328
+
329
+ flags = {}
330
+ (flags_blob&.dig("gates") || {}).each do |name, gate|
331
+ flags[name] = flag_ov.key?(name) ? flag_ov[name] : Eval.eval_gate(gate, u)
332
+ end
333
+
334
+ configs = {}
335
+ (flags_blob&.dig("configs") || {}).each do |name, entry|
336
+ configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
337
+ end
338
+
339
+ experiments = {}
340
+ (exps_blob&.dig("experiments") || {}).each do |name, exp|
341
+ if exp_ov.key?(name)
342
+ ov = exp_ov[name]
343
+ experiments[name] = { "inExperiment" => true, "group" => ov[:group], "params" => ov[:params] }
344
+ next
345
+ end
346
+ r = Eval.eval_experiment(exp, flags_blob, exps_blob, u, exp_name: name, sticky_store: sticky)
347
+ experiments[name] = { "inExperiment" => r.in_experiment, "group" => r.group, "params" => r.params }
348
+ end
349
+
350
+ { "flags" => flags, "configs" => configs, "experiments" => experiments, "killswitches" => {} }
351
+ end
352
+
353
+ # Return the cross-platform SSR bootstrap <script> tag for a request:
354
+ # se-bootstrap.js reads its data-* attributes and hydrates
355
+ # window.__SE_BOOTSTRAP (and writes the anon cookie). No key is embedded.
356
+ def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
357
+ payload = evaluate(user)
358
+ base = cdn_base(base_url)
359
+ attrs = [
360
+ "data-se-bootstrap",
361
+ attr("data-flags", JSON.generate(payload["flags"])),
362
+ attr("data-configs", JSON.generate(payload["configs"])),
363
+ attr("data-experiments", JSON.generate(payload["experiments"])),
364
+ attr("data-killswitches", JSON.generate(payload["killswitches"])),
365
+ attr("data-i18n-profile", i18n_profile || "en:prod"),
366
+ attr("data-api-url", base),
367
+ ]
368
+ attrs << attr("data-anon-id", anon_id) if anon_id && !anon_id.empty?
369
+ %(<script src="#{CGI.escapeHTML("#{base}/sdk/bootstrap.js")}" #{attrs.join(' ')}></script>)
370
+ end
371
+
372
+ # Return the i18n loader <script> tag (framework-agnostic; the Rails view
373
+ # helper Shipeasy::I18n::ViewHelpers#i18n_script_tag is separate). The
374
+ # loader fetches translations for the profile using the PUBLIC client key.
375
+ def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
376
+ base = cdn_base(base_url)
377
+ %(<script src="#{CGI.escapeHTML("#{base}/sdk/i18n/loader.js")}" ) +
378
+ %(#{attr('data-key', client_key)} #{attr('data-profile', profile || 'en:prod')}></script>)
379
+ end
380
+
274
381
  def track(user_id, event_name, props = {})
275
382
  return if @test_mode
276
383
 
@@ -427,6 +534,14 @@ module Shipeasy
427
534
  v.nil? || v == ""
428
535
  end
429
536
 
537
+ def cdn_base(override)
538
+ (override && !override.empty? ? override : DEFAULT_CDN_BASE).chomp("/")
539
+ end
540
+
541
+ def attr(name, value)
542
+ %(#{name}="#{CGI.escapeHTML(value.to_s)}")
543
+ end
544
+
430
545
  def start_poll
431
546
  @timer = Thread.new do
432
547
  loop do
@@ -499,6 +614,5 @@ module Shipeasy
499
614
  http.read_timeout = 10
500
615
  http.post(uri.request_uri, body, { "X-SDK-Key" => @api_key, "Content-Type" => "text/plain" })
501
616
  end
502
- end
503
617
  end
504
618
  end
@@ -33,7 +33,7 @@ module Shipeasy
33
33
  end
34
34
 
35
35
  # The anon id RackMiddleware resolved for the current request, or nil when
36
- # no middleware ran (e.g. a background job). FlagsClient falls back to this
36
+ # no middleware ran (e.g. a background job). The Engine falls back to this
37
37
  # as the default anonymous_id, so evaluations need no per-call wiring.
38
38
  def current
39
39
  Thread.current[THREAD_KEY]
@@ -11,7 +11,7 @@
11
11
  # require "open_feature/sdk"
12
12
  # require "shipeasy/sdk/openfeature"
13
13
  #
14
- # client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
14
+ # client = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
15
15
  # client.init
16
16
  #
17
17
  # OpenFeature::SDK.configure do |config|
@@ -22,7 +22,7 @@
22
22
  # on = of.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
23
23
  # evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u1"))
24
24
  #
25
- # Pure adapter over `FlagsClient` — no change to evaluation. Boolean values map
25
+ # Pure adapter over `Shipeasy::Engine` — no change to evaluation. Boolean values map
26
26
  # onto gates (`get_flag_detail`); string/number/integer/float/object map onto
27
27
  # dynamic configs (`get_config`).
28
28
 
@@ -37,12 +37,12 @@ rescue LoadError => e
37
37
  "gem \"openfeature-sdk\". (#{e.message})"
38
38
  end
39
39
 
40
- require_relative "flags_client"
40
+ require_relative "../engine"
41
41
 
42
42
  module Shipeasy
43
43
  module OpenFeature
44
44
  # Shipeasy OpenFeature provider (server paradigm). Wraps a
45
- # `Shipeasy::SDK::FlagsClient`; evaluation is local against the cached blob,
45
+ # `Shipeasy::Engine`; evaluation is local against the cached blob,
46
46
  # so resolution is effectively synchronous.
47
47
  class Provider
48
48
  OF = ::OpenFeature::SDK::Provider
@@ -56,12 +56,12 @@ module Shipeasy
56
56
  # FLAG_NOT_FOUND → ERROR (error_code FLAG_NOT_FOUND)
57
57
  # CLIENT_NOT_READY → ERROR (error_code PROVIDER_NOT_READY)
58
58
  REASON_MAP = {
59
- Shipeasy::SDK::FlagsClient::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
60
- Shipeasy::SDK::FlagsClient::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
61
- Shipeasy::SDK::FlagsClient::REASON_OFF => [OF::Reason::DISABLED, nil],
62
- Shipeasy::SDK::FlagsClient::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
63
- Shipeasy::SDK::FlagsClient::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
64
- Shipeasy::SDK::FlagsClient::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
59
+ Shipeasy::Engine::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
60
+ Shipeasy::Engine::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
61
+ Shipeasy::Engine::REASON_OFF => [OF::Reason::DISABLED, nil],
62
+ Shipeasy::Engine::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
63
+ Shipeasy::Engine::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
64
+ Shipeasy::Engine::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
65
65
  }.freeze
66
66
 
67
67
  attr_reader :metadata
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.6.0"
3
+ VERSION = "2.0.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -3,7 +3,8 @@ require_relative "shipeasy/config"
3
3
  require_relative "shipeasy/sdk/murmur3"
4
4
  require_relative "shipeasy/sdk/eval"
5
5
  require_relative "shipeasy/sdk/sticky_store"
6
- require_relative "shipeasy/sdk/flags_client"
6
+ require_relative "shipeasy/engine"
7
+ require_relative "shipeasy/client"
7
8
  require_relative "shipeasy/sdk/anon_id"
8
9
  require_relative "shipeasy/sdk/rack_middleware"
9
10
  require_relative "shipeasy/i18n/label_fetcher"
@@ -19,16 +20,16 @@ end
19
20
 
20
21
  module Shipeasy
21
22
  module SDK
22
- # Convenience constructor. Reads api_key + base_url from the gem-wide
23
- # config when omitted, so a single `Shipeasy.configure { }` block at
24
- # boot is enough.
23
+ # Convenience constructor for a heavyweight Engine. Reads api_key + base_url
24
+ # from the gem-wide config when omitted. Most apps should prefer
25
+ # `Shipeasy.configure { … }` + `Shipeasy::Client.new(user)` instead.
25
26
  def self.new_client(api_key: Shipeasy.config.api_key, base_url: Shipeasy.config.base_url)
26
- FlagsClient.new(api_key: api_key, base_url: base_url)
27
+ Shipeasy::Engine.new(api_key: api_key, base_url: base_url)
27
28
  end
28
29
 
29
30
  # ---- see() module-level facade --------------------------------------
30
31
  #
31
- # Backed by a default client, registered when a FlagsClient is constructed
32
+ # Backed by a default client, registered when an Engine is constructed
32
33
  # (last constructed wins). Mirrors the package-level see() in the TS/Python
33
34
  # SDKs so callers can `Shipeasy::SDK.see(e).causes_the(...).to(...)` without
34
35
  # threading a client reference through every call site. A call before any
@@ -38,7 +39,7 @@ module Shipeasy
38
39
  @see_default_mutex = Mutex.new
39
40
 
40
41
  # Register the client backing the module-level see() funcs. Called
41
- # automatically from FlagsClient#initialize; also exposed for explicit use.
42
+ # automatically from Engine#initialize; also exposed for explicit use.
42
43
  def self.set_default_client(client)
43
44
  @see_default_mutex.synchronize { @see_default_client = client }
44
45
  client
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-20 00:00:00.000000000 Z
11
+ date: 2026-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -65,13 +65,14 @@ files:
65
65
  - LICENSE
66
66
  - README.md
67
67
  - lib/shipeasy-sdk.rb
68
+ - lib/shipeasy/client.rb
68
69
  - lib/shipeasy/config.rb
70
+ - lib/shipeasy/engine.rb
69
71
  - lib/shipeasy/i18n/label_fetcher.rb
70
72
  - lib/shipeasy/i18n/railtie.rb
71
73
  - lib/shipeasy/i18n/view_helpers.rb
72
74
  - lib/shipeasy/sdk/anon_id.rb
73
75
  - lib/shipeasy/sdk/eval.rb
74
- - lib/shipeasy/sdk/flags_client.rb
75
76
  - lib/shipeasy/sdk/murmur3.rb
76
77
  - lib/shipeasy/sdk/openfeature.rb
77
78
  - lib/shipeasy/sdk/rack_middleware.rb