Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

- `Bundlebun.system(args)` method: runs Bun as a subprocess and returns to Ruby. Returns `true` if Bun exited successfully. Use this when you need to continue executing Ruby code after Bun finishes.
- Default behaviour remains: `Bundlebun.()` / `Bundlebun.call` / `Bundlebun.exec` all replace the current Ruby process with Bun (never return). This is what binstubs and wrappers use.

- ActiveSupport::Notifications instrumentation added. `system.bundlebun` for `Bundlebun.system` calls, `exec.bundlebun` for exec calls (binstubs, wrappers). Payload: `{ command: args }`.
- Added RBS type signatures for the public API

## [0.3.0] - 2026-01-29
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ Alternatively, you can load the monkey-patch manually:
Bundlebun::Integrations::ExecJS.bun!
```

#### ActiveSupport::Notifications (Instrumentation)

When ActiveSupport is available, bundlebun emits instrumentation events that you can subscribe to for logging or monitoring:

- `system.bundlebun` for `Bundlebun.system` (calling Bun and returning to Ruby code);
- `exec.bundlebun` for `Bundlebun.call` / `Bundlebun.exec` (binstubs and wrappers: exiting to Bun).

The payload is `{ command: args }`: the arguments you passed to the method (string or array).

Example subscriber:

```ruby
ActiveSupport::Notifications.subscribe('system.bundlebun') do |event|
Rails.logger.info "Bun: #{event.payload[:command]} (#{event.duration.round(1)}ms)"
end
```

## Versioning

bundlebun uses the `#{bundlebun.version}.#{bun.version}` versioning scheme. Meaning: gem bundlebun version `0.1.0.1.1.38` is a distribution that includes a gem with its own code version `0.1.0` and a Bun runtime with version `1.1.38`.
Expand Down
6 changes: 3 additions & 3 deletions lib/bundlebun.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class << self
# Replaces the current Ruby process with Bun.
#
# This is the default way to run Bun. The Ruby process is replaced by Bun
# and never returns. Also available via the +.()+ shorthand syntax.
# and never returns. Also available via the `.()` shorthand syntax.
#
# @param arguments [String, Array<String>] Command arguments to pass to Bun
# @return [void] This method never returns
Expand Down Expand Up @@ -70,8 +70,8 @@ def exec(...)
# Use this when you need to run Bun and then continue executing Ruby code.
#
# @param arguments [String, Array<String>] Command arguments to pass to Bun
# @return [Boolean, nil] +true+ if Bun exited successfully (status 0),
# +false+ if it exited with an error, +nil+ if execution failed
# @return [Boolean, nil] `true` if Bun exited successfully (status 0),
# `false` if it exited with an error, `nil` if execution failed
#
# @example Run install and check result
# if Bundlebun.system('install')
Expand Down
42 changes: 36 additions & 6 deletions lib/bundlebun/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class Runner
class << self
# Replaces the current Ruby process with Bun.
#
# When `ActiveSupport::Notifications` is available, this method publishes an
# `exec.bundlebun` event before replacing the process. The payload contains
# `{ command: arguments }` where `arguments` is what was passed to the method.
#
# @param arguments [String, Array<String>] Command arguments to pass to Bun
# @return [void] This method never returns
#
Expand All @@ -43,7 +47,7 @@ def exec(...)
end

# Replaces the current Ruby process with Bun. Alias for {.exec}.
# Also available via the +.()+ shorthand syntax.
# Also available via the `.()` shorthand syntax.
#
# @param arguments [String, Array<String>] Command arguments to pass to Bun
# @return [void] This method never returns
Expand All @@ -66,9 +70,13 @@ def call(...)
# Unlike {.call} and {.exec}, this method does not replace the current process.
# Use this when you need to run Bun and then continue executing Ruby code.
#
# When `ActiveSupport::Notifications` is available, this method publishes a
# `system.bundlebun` event with timing information. The payload contains
# `{ command: arguments }` where `arguments` is what was passed to the method.
#
# @param arguments [String, Array<String>] Command arguments to pass to Bun
# @return [Boolean, nil] +true+ if Bun exited successfully (status 0),
# +false+ if it exited with an error, +nil+ if execution failed
# @return [Boolean, nil] `true` if Bun exited successfully (status 0),
# `false` if it exited with an error, `nil` if execution failed
#
# @example Run install and check result
# if Bundlebun.system('install')
Expand Down Expand Up @@ -172,6 +180,10 @@ def initialize(arguments = '')
# Replaces the current Ruby process with Bun.
# This is the default behavior.
#
# When `ActiveSupport::Notifications` is available, this method publishes an
# `exec.bundlebun` event before replacing the process. The payload contains
# `{ command: arguments }` where `arguments` is what was passed to the runner.
#
# @return [void] This method never returns
#
# @example
Expand All @@ -181,6 +193,7 @@ def initialize(arguments = '')
# @see #system
def exec
check_executable!
instrument('exec.bundlebun')
Kernel.exec(command)
end

Expand All @@ -198,8 +211,12 @@ def call
# Unlike {#call} and {#exec}, this method does not replace the current process.
# Use this when you need to run Bun and then continue executing Ruby code.
#
# @return [Boolean, nil] +true+ if Bun exited successfully (status 0),
# +false+ if it exited with an error, +nil+ if execution failed
# When `ActiveSupport::Notifications` is available, this method publishes a
# `system.bundlebun` event with timing information. The payload contains
# `{ command: arguments }` where `arguments` is what was passed to the runner.
#
# @return [Boolean, nil] `true` if Bun exited successfully (status 0),
# `false` if it exited with an error, `nil` if execution failed
#
# @example
# runner = Bundlebun::Runner.new('install')
Expand All @@ -210,13 +227,26 @@ def call
# @see #exec
def system
check_executable!
Kernel.system(command)
instrument('system.bundlebun') do
Kernel.system(command)
end
end

private

attr_reader :arguments

def instrument(event, &block)
return block&.call unless defined?(ActiveSupport::Notifications)

payload = {command: arguments}
if block
ActiveSupport::Notifications.instrument(event, payload, &block)
else
ActiveSupport::Notifications.instrument(event, payload)
end
end

def check_executable!
return if self.class.binary_path_exist?

Expand Down
73 changes: 73 additions & 0 deletions spec/integration/instrumentation_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

require 'active_support'
require 'active_support/notifications'

RSpec.describe 'ActiveSupport::Notifications instrumentation', type: :integration do
let(:binary_path) { Bundlebun::Runner.binary_path }
let(:events) { [] }

before do
allow(File).to receive(:exist?).with(binary_path).and_return(true)
allow(Kernel).to receive(:system).and_return(true)
allow(Kernel).to receive(:exec)
end

describe 'system.bundlebun' do
before do
ActiveSupport::Notifications.subscribe('system.bundlebun') do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
end

after do
ActiveSupport::Notifications.unsubscribe('system.bundlebun')
end

it 'emits an event with command payload' do
Bundlebun.system('install')

expect(events.size).to eq(1)
expect(events.first.name).to eq('system.bundlebun')
expect(events.first.payload).to eq({command: 'install'})
end

it 'includes timing information' do
Bundlebun.system('build')

expect(events.first.duration).to be >= 0
end

it 'works with array arguments' do
Bundlebun.system(['add', 'postcss'])

expect(events.first.payload).to eq({command: ['add', 'postcss']})
end
end

describe 'exec.bundlebun' do
before do
ActiveSupport::Notifications.subscribe('exec.bundlebun') do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
end

after do
ActiveSupport::Notifications.unsubscribe('exec.bundlebun')
end

it 'emits an event with command payload' do
Bundlebun.call('install')

expect(events.size).to eq(1)
expect(events.first.name).to eq('exec.bundlebun')
expect(events.first.payload).to eq({command: 'install'})
end

it 'works with array arguments' do
Bundlebun.call(['run', 'dev'])

expect(events.first.payload).to eq({command: ['run', 'dev']})
end
end
end
56 changes: 56 additions & 0 deletions spec/lib/bundlebun/runner_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,60 @@
expect(described_class.system('--version')).to be true
end
end

describe 'instrumentation' do
before do
allow(File).to receive(:exist?).with(binary_path).and_return(true)
stub_const('ActiveSupport::Notifications', double('ActiveSupport::Notifications'))
end

describe '#exec' do
it 'instruments exec.bundlebun before replacing process' do
runner = described_class.new('install')
expect(ActiveSupport::Notifications).to receive(:instrument)
.with('exec.bundlebun', {command: 'install'})
expect(Kernel).to receive(:exec)
runner.exec
end
end

describe '#system' do
it 'instruments system.bundlebun with a block' do
runner = described_class.new('install')
expect(ActiveSupport::Notifications).to receive(:instrument)
.with('system.bundlebun', {command: 'install'})
.and_yield
expect(Kernel).to receive(:system).and_return(true)
runner.system
end

it 'passes the command as payload' do
runner = described_class.new(['add', 'postcss'])
expect(ActiveSupport::Notifications).to receive(:instrument)
.with('system.bundlebun', {command: ['add', 'postcss']})
.and_yield
expect(Kernel).to receive(:system).and_return(true)
runner.system
end
end
end

describe 'without ActiveSupport' do
before do
allow(File).to receive(:exist?).with(binary_path).and_return(true)
# ActiveSupport::Notifications is not defined in this context
end

it 'still works for #system' do
runner = described_class.new('install')
expect(Kernel).to receive(:system).and_return(true)
expect(runner.system).to be true
end

it 'still works for #exec' do
runner = described_class.new('install')
expect(Kernel).to receive(:exec)
runner.exec
end
end
end