Verbatim port of Fastway-Server's TFWEventBus from fw_plugin_host.pas
per feedback_copy_dont_reinterpret.md. Adjustments limited to:
- Type renames (TFW* -> T*).
- uses clause: drop fw_log; add log.types from fpc-log so the
optional Logger property uses the canonical ecosystem-wide
TLogProc shape, matching every other fpc-* library.
- Per-handler exception logging now calls Logger with
Level=llError, Category='events', and includes the source
plugin (ASourcePlugin parameter) in the message text so the
canonical signature stays meaningful.
Behaviours preserved verbatim: APluginName bulk-Unsubscribe key,
wildcard '*' subscriber, OnBroadcast external-listener tap,
snapshot-iterate-outside-lock pattern, per-handler exception
isolation, TCriticalSection.
docs/DEVELOPER_GUIDE.md added covering threading, payload
ownership, recursive Fire, OnBroadcast, logger plumbing, and
the relationship between fpc-events (ecosystem-wide pub/sub)
and per-library typed observer callbacks (bp.events / cm.events
pattern).
Tests: 44 assertions across 14 scenarios pass on x86_64-linux.
Pre-tag -vh audit on src/ev.bus.pas reports zero hints/warnings.
fpc-events
Thread-safe publish/subscribe event bus for Free Pascal. Verbatim
port of the TFWEventBus primitive from Fastway-Server's
fw_plugin_host.pas — same surface, same semantics, no
Fastway-specific dependencies.
What it is
- One class,
TEventBus, that delivers(EventType, JSONObject)payloads to registered subscribers. - Subscribers are method-of-object callbacks; they can be removed
in bulk by group key (
Unsubscribe(plugin_name)) or one-by-one by callback identity (UnsubscribeCallback). - Wildcard subscribers (
'*') receive every event fired. - A single optional
OnBroadcasttap fires once perFirecall, for forwarding events out of band (the original use case is pushing events to web-admin WebSocket clients). - Per-handler exceptions are isolated — a raising subscriber does
not block delivery to the others, and the optional
Loggercallback receives the exception text. - Snapshot-iterate semantics: a callback can safely Subscribe / Unsubscribe / Fire recursively without deadlocking the registry.
Quick start
uses
Classes, SysUtils, fpjson, events.bus;
type
TListener = class
procedure HandleLogin(const AEventType: string; AData: TJSONObject);
end;
procedure TListener.HandleLogin(const AEventType: string; AData: TJSONObject);
begin
Writeln('user.login: ', AData.Get('username', '?'));
end;
var
Bus: TEventBus;
L: TListener;
Data: TJSONObject;
begin
Bus := TEventBus.Create;
L := TListener.Create;
try
Bus.Subscribe('demo', 'user.login', @L.HandleLogin);
Data := TJSONObject.Create;
try
Data.Add('username', 'alice');
Bus.Fire('auth', 'user.login', Data);
finally
Data.Free;
end;
finally
L.Free;
Bus.Free;
end;
end.
See examples/pubsub.pas for a more complete demo with wildcard
subscribers and bulk unsubscribe, and
docs/DEVELOPER_GUIDE.md for the
full developer guide covering threading, payload ownership,
recursive Fire, and the relationship with per-library typed
callbacks.
API surface
| Method / property | Purpose |
|---|---|
Create |
Construct an empty bus. |
Subscribe(APluginName, AEventType, ACallback) |
Register a callback for AEventType. '*' matches every event. APluginName is a free-form group key for bulk removal; pass '' if unused. |
Unsubscribe(APluginName) |
Remove every subscription whose PluginName matches (case-insensitive). |
UnsubscribeCallback(ACallback) |
Remove every subscription whose method pointer (Code+Data) matches. |
Fire(ASourcePlugin, AEventType, AData) |
Deliver AEventType+AData to every matching subscriber, then fire OnBroadcast if assigned. Caller retains ownership of AData. |
GetSubscriptionCount |
Number of registered subscriptions. |
OnBroadcast: TEventBroadcast |
External-listener tap fired once per Fire. |
Logger: log.types.TLogProc |
Optional sink for per-handler exception messages. nil = silent. Same shape as every other fpc-* library. |
Types
TEventCallback = procedure(const AEventType: string; AData: TJSONObject) of object;TEventBroadcast = procedure(const AEventType: string; AData: TJSONObject) of object;Logger-property type islog.types.TLogProc(from fpc-log) — the ecosystem-wide logger shape.
Dependencies
- fpc-log — only
log.types.TLogProcfor the optionalLoggerproperty.
Notes on behaviour
Subscribedoes not deduplicate. Registering the same callback twice means it fires twice.Fire's subscription delivery order matches insertion order.ADataownership stays with the caller ofFire; subscribers must not free it. The most common pattern isData := TJSONObject.Create; try Bus.Fire(...); finally Data.Free; end;.- The
ASourcePluginparameter onFireis currently passthrough metadata — the bus itself does not read it. The original Fastway use was provenance tagging at the call site; consumers can ignore it. - All operations are thread-safe via a single
TCriticalSection.Firesnapshots subscriptions under the lock and iterates outside it, so re/un-subscribe and recursiveFirefrom inside a callback are safe.
Build and test
bash build.sh # compile every src/*.pas
bash run_tests.sh # build + run every tests/test_*.pas, compile every example
build.sh is single-target (x86_64-linux). Cross-compilation
is the consumer's responsibility — events.version.pas and events.bus.pas
are pure FPC + RTL (Classes, SysUtils, fpjson, syncobjs)
and have no platform-specific code.
Versioning
Pin downstream consumers by tag (v0.1.0), not commit hash.
Constants in src/events.version.pas:
EVENTS_VERSION_MAJOR = 0;
EVENTS_VERSION_MINOR = 1;
EVENTS_VERSION_PATCH = 0;
EVENTS_VERSION_STRING = '0.1.0';
Semver intent:
- major — breaks callers (API removal, signature change)
- minor — additive features
- patch — bug fixes, internal cleanups
License
Same as Fastway-Server: MIT (see LICENSE if/when added by the consumer). Until then, treat the source as Fastway-Server-licensed.