Skip to content

feat: Drupal.rl thin JS API with request batching#45

Merged
jjroelofs merged 6 commits into
1.xfrom
42-js-api-batching
Apr 15, 2026
Merged

feat: Drupal.rl thin JS API with request batching#45
jjroelofs merged 6 commits into
1.xfrom
42-js-api-batching

Conversation

@jjroelofs
Copy link
Copy Markdown
Contributor

Closes #42.

Summary

Introduces a shared Drupal.rl transport layer so multiple RL consumers on the same page coalesce into a small, fixed number of requests instead of one pair per experiment. On a page running a hero CTA experiment plus rl_page_title plus four rl_menu_link experiments, network traffic drops from ~12 requests per page view to 2.

  • js/rl.js exposes Drupal.rl.decide / .turn / .reward / .flush.
    • Decides flush on the next tick (setTimeout 0), which catches every module registering synchronously during Drupal.behaviors.attach in a single batch without any added latency.
    • Turns and rewards flush in a 500 ms window so interaction-triggered events coalesce.
    • visibilitychange and pagehide flush buffered events through navigator.sendBeacon so nothing is lost on navigation.
    • Modelled on Drupal.history in Drupal core.
  • rl.php collapses the legacy turn / turns / reward / decide form-encoded actions into a single action=batch JSON endpoint (payload shape documented in the handle_batch_request() docblock). action=ping is preserved for the hook_requirements() health check.
  • rl_page_attachments() publishes drupalSettings.rl.endpointUrl so consumer modules no longer recompute and attach the URL themselves.
  • All four consumer modules migrated:
    • rl_example_frontend - replaces the broken action=scores call with Drupal.rl.decide().
    • rl_example - replaces direct sendBeacon with Drupal.rl.turn().
    • rl_menu_link - uses Drupal.rl.turn / .reward.
    • rl_page_title - uses Drupal.rl.turn / .reward.
    • Each *.libraries.yml now depends on rl/api.
  • Dead code cleanup: NewsletterBlock in rl_example and rl_example_frontend drop the now-unused moduleExtensionList and requestStack injections since they no longer build the endpoint URL.
  • Docs (README, submodule READMEs, rl_project_desc.html) updated to describe the new JS API and removed stale sendBeacon snippets.

Request reduction

Before After
1 decide + 1 turn per client-side experiment 1 shared decide batch (next tick)
1 turn + 1 reward per event 1 shared track batch per 500 ms window
Separate sendBeacon POST per event on navigation Single batched sendBeacon on pagehide

Test plan

  • Load a page with rl_example_frontend, rl_page_title, and several rl_menu_link experiments active.
  • DevTools Network panel: confirm exactly one POST to rl.php?action=batch at page load, with a JSON body containing all pending decides and initial turns.
  • Scroll / click-induced turns and rewards flush as a second batch within 500 ms.
  • Navigate away: confirm a final sendBeacon POST to rl.php?action=batch carries any buffered events.
  • drush rl:status still reflects the recorded turns and rewards after the above.
  • /admin/reports/status shows the rl.php accessibility requirement as OK (action=ping still works).
  • rl_example_frontend newsletter button actually renders a variant (decide resolves), tracks a turn when the form enters the viewport, and tracks a reward on click.

Jurriaan Roelofs added 2 commits April 15, 2026 14:18
Introduces a shared Drupal.rl transport layer so multiple RL consumers on
the same page produce ~2 requests instead of one per experiment:

- js/rl.js exposes Drupal.rl.decide / turn / reward / flush. Decides
  flush on the next tick (catching every module that registers in
  Drupal.behaviors.attach); turns and rewards flush in a 500 ms window
  and via sendBeacon on visibilitychange / pagehide.
- rl.php collapses the legacy turn/turns/reward/decide form handlers
  into a single action=batch JSON endpoint. ping is preserved for the
  hook_requirements() health check.
- rl_page_attachments() publishes drupalSettings.rl.endpointUrl so
  consumer modules no longer have to compute and attach the URL
  themselves.
- All four consumer modules (rl_example, rl_example_frontend,
  rl_menu_link, rl_page_title) migrated to the Drupal.rl API. The
  broken action=scores call in rl_example_frontend is replaced with
  Drupal.rl.decide().
- README and docs updated to describe the new JS API.
Drupal.rl is only one consumer of the batch endpoint. Native mobile
apps, server-side workers, other CMSes, and edge functions can POST to
rl.php directly using the same JSON protocol. Document the wire format,
validation rules, error responses, and curl examples in both README.md
and the project description HTML so those callers do not have to read
rl.php to integrate.
@jjroelofs
Copy link
Copy Markdown
Contributor Author

I took a close pass through the new batching client, rl.php, and the migrated consumers. Two issues look blocking to me, plus one doc regression:

  1. rl.php now hard-fails every legacy form-encoded tracking call except ping (rl.php:16-18, rl.php:35-37). On 1.x that is a breaking API change, because the previous branch accepted action=turn, action=turns, and action=reward, and the README had been documenting direct rl.php usage as the recommended integration path. Any site or custom module still posting those shapes will start getting 400 Invalid action and silently stop recording data after upgrading. I think this needs either a compatibility shim / deprecation period, or the break needs to be held for a major-version line.

  2. flushBeacon() rejects buffered decide promises whenever the document becomes hidden (js/rl.js:165-183, js/rl.js:251-256). visibilitychange also fires for ordinary tab/app backgrounding, not just unload. So if a page queues Drupal.rl.decide() and the user backgrounds the tab before the fetch completes, we drop the pending decision, the consumer falls into its .catch() path, and the page never retries even if the user comes back to the same tab. I’d keep the hidden/pagehide beacon path for fire-and-forget turns/rewards, but not for decides that still need a response.

  3. The post-install smoke test in the README still tells users to POST action=turns (README.md:69-70), which now returns 400 on this branch. That means a fresh reviewer/admin following the install docs gets a false-negative health check even though the intended replacement is action=ping.

I also ran php -l over the changed PHP entrypoints/modules and didn’t hit syntax issues.

Jurriaan Roelofs added 4 commits April 15, 2026 14:39
Responds to the arm-ownership / ai_sorting feedback on #45. Deciding
which variant to show belongs in PHP at render time where the consumer
already owns the arm list (see ai_sorting's Views sort plugin and
VariantSelectorBase in this module). Client-side decides would force
runtime JS to know the current arm set, which drifts out of sync when
experiment managers add or remove variants.

- rl.php is now strictly additive: action=ping, action=turn,
  action=turns, and action=reward legacy form handlers are restored
  unchanged so ai_sorting and any other production consumer keeps
  working. The new action=batch sits alongside them as a JSON endpoint
  that Drupal.rl speaks.
- action=batch carries only turns and rewards. The decides section is
  gone, and the response is now {"ok":true} instead of a decisions map.
- js/rl.js drops Drupal.rl.decide() entirely and all of its promise /
  queue machinery. The API is now just turn(), reward(), and flush()
  plus pagehide sendBeacon.
- rl_example_frontend is converted to the canonical pattern: the block
  decides the winning variant in PHP inside build() using
  ExperimentManager::getThompsonScores(), server-renders the winning
  button text, and exposes the arm id to Drupal.rl.turn() / .reward()
  via drupalSettings. Block picks up a 60-second cache max-age so the
  server-chosen variant can rotate as scores evolve.
- README and HTML docs are rewritten around this split: "deciding"
  section for the PHP pattern, "JS API" section for tracking, "HTTP
  API" section documenting all five actions (ping / turn / turns /
  reward / batch) as peers.
- Add missing @param descriptions for $registry and $storage in
  handle_batch_request().
- Replace direct $_GET access with filter_input(INPUT_GET, ...) so the
  Drupal coding standards sniff for super globals stays happy.
  request_stack is not usable here because action dispatch has to
  happen before the Drupal kernel boots.
Absorbs PR #44 into the Drupal.rl batch transport so client-decide
consumers (DXPR Builder and similar full-page-cache builders) share
a single round trip with the turn/reward tracking already batched by
Drupal.rl.

- js/rl.js gains Drupal.rl.decide(experimentId, armIds) returning a
  Promise<armId>. Decides share the same 500 ms queue and the same
  POST as turns and rewards, so a page with a variant block plus
  other RL tracking ends up making one request instead of two.
  Fallback on server failure or missing decision resolves to
  armIds[0] so callers never need a .catch() for the common path.
- rl.php handle_batch_request processes an optional decides section,
  calls ExperimentManager::getThompsonScores to seed cold-start
  priors, and returns a decisions map keyed by experiment id. The
  batch response becomes {"ok":true,"decisions":{...}}.
- Documentation adds a "decide discipline" section: callers must
  read arm ids from a DOM attribute that the server-side renderer
  emitted, never hardcode. This mirrors ai_sorting's PHP pattern of
  recomputing arm ids from the current view query on every render
  and keeps JS drift-free without forcing the rl core to store arm
  lists. The convention (v0..vN, UUIDs, node ids) is whatever the
  builder emits; rl core is arm-agnostic.
@jjroelofs jjroelofs merged commit a634024 into 1.x Apr 15, 2026
2 of 3 checks passed
@jjroelofs jjroelofs deleted the 42-js-api-batching branch April 15, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Drupal.rl thin JS API with request batching

1 participant