I found a way that addresses my needs. It turns out I was searching for the wrong keywords, when searching for PowerShell and callbacks I found this question which helped me a lot (particularly Duncan's answer):
Pass a function as a parameter in PowerShell
I worked it into a full example. This is a client script, to be saved as "Client.ps1":
Import-Module -Name ".\Server.psm1" -DisableNameChecking
$script:ClientVar = "Not seen by server, returned by event handler."
function Handle_Initialized {
Write-Host "Handler Initialized is being called."
# a return value is optional
return "=== $script:ClientVar ==="
}
function Handle_ProcessedData {
param (
$Argument1,
$Argument2,
$Argument3
)
Write-Host "Handler ProcessedData is called."
Write-Host "Arguments are $argument1, $argument2 and $argument3."
# a return value is optional
return "=== $argument1, $argument2, $argument3 ==="
}
Subscribe-Event -Name Initialized -Handler $function:Handle_Initialized
Subscribe-Event -Name ProcessedData -Handler $function:Handle_ProcessedData
Write-Host ""
Write-Host "calling with active subscriptions"
Write-Host "================================="
Do-ServerStuff
Unsubscribe-Event -Name Initialized
Unsubscribe-Event -Name ProcessedData
Write-Host ""
Write-Host "calling again with no active subscriptions"
Write-Host "================================="
Do-ServerStuff
Remove-Module -Name "Server"
Then, in the same folder, put this as "Server.psm1":
[ScriptBlock]$script:Handler_Initialized = $null
[ScriptBlock]$script:Handler_ProcessedData = $null
function Subscribe-Event {
param (
[String]$Name,
[ScriptBlock]$Handler
)
switch ($Name) {
Initialized { $script:Handler_Initialized = $Handler }
ProcessedData { $script:Handler_ProcessedData = $Handler }
}
}
function Unsubscribe-Event {
param (
[String]$Name
)
switch ($Name) {
Initialized { $script:Handler_Initialized = $null }
ProcessedData { $script:Handler_ProcessedData = $null }
}
}
function Raise-Initialized {
param (
)
if ($script:Handler_Initialized) {
return & $script:Handler_Initialized
}
}
function Raise-ProcessedData {
param (
[Object]$Argument1,
[Object]$Argument2,
[Object]$Argument3
)
if ($script:Handler_ProcessedData) {
return & $script:Handler_ProcessedData -Argument1 $Argument1 -Argument2 $Argument2 -Argument3 $Argument3
}
}
function Do-ServerStuff {
Write-Host "Before raising event Initialized."
Raise-Initialized
Write-Host "After raising event Initialized."
Write-Host ""
Write-Host "Before raising event ProcessedData."
Raise-ProcessedData -Argument1 "AAA" -Argument2 "BBB" -Argument3 "CCC"
Write-Host "After raising event ProcessedData."
}
And you will find that you have an extensible synchronous event handling system.
Most of the plumbing is in the server module which dictates the prototypes for the handler functions. The client code merely subscribes to and unsubscribes from events and provides the handler implementations. It supports named parameters for all handlers as well as return values (really output values) in the usual PowerShell way. The server knows nothing about any of its clients which I like a lot, dependencies go one way only.
For example, this scheme allows you to build modules that perform pure core logic and nothing else. Rather than logging through some global object you can have the server module raise an event that sends a message and the client can then decide what to do with it and where to send it. This increases the usability of server modules and makes them testable.
It may be a matter of taste, I typically favor using events over dependency injection. With dependency injection the server still needs to know about the type that is injected, with events this is not the case.
Enjoy!