Studio Developer Manual / Version 2404
Table Of ContentsIn this section the basics of the Service Agent API are described. Built-in services of CoreMedia Studio Apps and other pre-defined service types are covered later on.
What is a Service?
The requirements for a service are quite simple and stem from the fact that a service
can only offer asynchronous methods due to the message communication layer that is used underneath.
A service must comply to the type constraint Service<T>
(from the @coremedia/service-agent
module)
where T
is the service's interface. This means in particular:
-
A service method can return an rxjs
Observable
. In that case, the method name must start with theobserve_
prefix. Covering the library rxjs for reactive streams is out of scope for this documentation. For more details, see the various online materials, e.g. RxJS Overview or Learn RxJS. -
If a service method does not return an
Observable
then is must return aPromise
. - No other methods are allowed.
- The service may have additional non-function properties.
How the Promises
and Observables
are handled across the BroadcastChannel
bweteen the different apps is hidden from the developer and taken over by the framework.
There is one additional requirement for services that is not covered by the
type constraint Service<T>
. As all service handling
is carried out via the browser's BroadcastChannel, the arguments
and return values of all service methods must be serializable for the
BroadcastChannel. Data sent to the channel is serialized
using the structured clone algorithm.
That means you can send a broad variety of data objects safely without having to serialize them yourself.
Registering Services
As shown in Figure Figure 9.27, “Different Studio Apps Connected Via Service Layer”, each app uses its own instance
of the Service Agent. When an app registers a service with its Service Agent,
this agent takes care of broadcasting the service to the Service Agents of
other apps.
The registration of a service is done via the ServiceAgent#registerService()
method. The following
code fragment shows the example where the Content App registers the ContentFormService
whch allows to open content items in a form tab.
getServiceAgent().registerService( new ContentFormServiceImpl(), createContentFormServiceDescriptor({ providingApp: studioApps._.getMyAppDescriptor().key }), );
A service is always registered together with a so-called service descriptor. The descriptor describes the
service in terms of specific properties where the name
property is the only madatory one. Upon querying
for services, another service descriptor is given and the Service Agent matches it
against the descriptors of registered services. More details follow below.
As stated above, the registered service needs to comply to the type constraint Service<T>
.
However, this only applies to the service interface. In the case above for example, ContentFormServiceImpl
has methods that do not comply to the Service<T>
constraint. But the class implements
the interface ContentFormService
which does comply to the constraint. There are two possibilities to
register ContentFormServiceImpl
with the Service Agent. Either it needs to
be cast / assigned to its interface ContentFormService
or the service descriptor
is used like in the example. The service descriptor for the ContentFormService
is defined like this:
import { serviceDescriptorFactory, ServiceDescriptorWithProps } from "@coremedia/service-agent"; import { WithProvidingApp } from "@coremedia/studio-client.app-context-models"; export interface ContentFormServiceProps extends WithProvidingApp {} export function createContentFormServiceDescriptor( props: ContentFormServiceProps = {}, ): ServiceDescriptorWithProps<ContentFormService, ContentFormServiceProps> { return serviceDescriptorFactory<ContentFormService, ContentFormServiceProps>({ ...props, name: "contentFormService", }); }
To define service descriptors, it is recommended to use the serviceDescriptorFactory
function
from the @coremedia/service-agent
module. It returns a ServiceDescriptorWithProps
which is typed for specific services and descriptor properties. In this case, the descriptor is for services
of type ContentFormService
and their additional properties of type
ContentFormServiceProps
(which currently just extends WithProvidingApp
).
So using this service descriptor for ServiceAgent#registerService()
fulfils two purposes:
(1) It defines the describing properties of the registered service and (2) it enforces that the
registered service is of type ContentFormService
which complies to the Service<T>
.
Registering Service Runners
Besides registering service it is also possible to register a service runner.
A service runner is a function that is called when a service is requested but no such service is running
at the moment. So it can be used to only launch a service once it is requested. It is done via
the ServiceAgent#registerServiceRunner()
method. It also takes a service descriptor
as its first argument and the runner function as its second argument.
An important example was already mentioned in Section Section 9.36.4, “App Manifest and Apps Menu Entries”. The services
of an app manifest's cmServices
are required to be set up upon app initialization. The
Studio Apps framework automatically sets up a service runner for each of these
services. In this case, the runner simply starts the app if it is not yet running. For example,
if the Content App is not yet running in a browser window, there is no running
service ContentFormService
in place. But because the ContentFormService
(more
specifically, its descriptor) is part of the Content App's cmServices
manifest entry, a runner for this service is already in place.
Un-Registering Services and Service Runners
To un-register a service or a service runner, the methods ServiceAgent#unregisterServices()
and ServiceAgent#unregisterServiceRunners()
are used. Both methods take a ServiceDescriptorWithProps
as their argument. All services whose descriptor matches the given descriptor are un-registered.
A service descriptor match is carried out based on lodash's isMatch()
method, see lodash documentation.
The descriptor argument of the unregister method is taken as the second argument of isMatch()
and the descriptor of the registered services / runners are taken as the first argument.
Consequently, the call ServiceAgent#unregisterServices({name: "someService", prop: "myValue"})
would unregister all services with the "name" value "someService" and the "prop" value "myValue",
no matter what other properties their descriptors have.
Querying / Requesting Services
The previous paragraphs cover the topic of registering services and service runners and using service descriptors for this purpose. Now the topic of querying / requesting services is covered.
There are two ways to request services.
ServiceAgent#getServices()
: Returns all services that are currently known.ServiceAgent#observeServices()
: Returns an rxjsObservable
of all known services. Once the known services change, theObservable
emits a new value. TheObservable
also always emits the current value on subscribe.
In both cases, a singular version exists (ServiceAgent#getService()
and ServiceAgent#observeService()
))
where the first matching service is returned.
The first argument that both methods take is a service descriptor. The descriptor is used
to match against the descriptors of the registered services. The matching is done based on lodash's
isMatch()
method. The descriptor argument of the request method is taken as the second argument of
isMatch()
and the descriptors of the registered services are taken as the first argument.
Consequently, the call ServiceAgent#getServices({name: "someService", prop: "myValue"})
would return all services with the "name" value "someService" and the "prop" value "myValue", no matter what
other properties their descriptors have. Just as for registering services, it is also recommended
for requesting services to use ServiceDescriptorsWithProps
as arguments.
For example, the code for registering the ContentFormService
in the Content App
was shown above. The My-Edited-Contents App uses this service to open a content item from
the edited contents list when clicked. The code looks as follows (the fetch()
of the
code fragment is explained below).
<IconButton color={"primary"} onClick={async () => { const contentFormService = await getServiceAgent() .getService(createContentFormServiceDescriptor())? .fetch(); contentFormService && await contentFormService.openContentForm(params.row.id); }}>
Because the ServiceDescriptorWithProps<ContentFormService, ContentFormServiceProps>
returned from createContentFormServiceDescriptor()
is used, it is possible to continue
in a type-safe way: Typescript knows that the returned service is of type ContentFormService
.
In addition to the service descriptor, both service request methods
take an optional second argument of type ServiceRetrievalOptions
.
Currently, there is only one option, namely ServiceRetrievalOptions#state
. It allows to specify whether
only running services shall be retrieved or also runnable services. The latter are services that are not yet
running but a service runner exists, see above. By default, all services are retrieved, running and runnable ones.
Using Services
The fact that both running and runnable services can be retrieved is also the reason that no services are
directly returned by the two request methods. Instead, a ServiceHandler
is returned for
each service in the result. A handler provides the descriptor for its service via ServiceHandler#descriptor
and
the state of its service ("running" or "runnable") via ServiceHandler#state
.
An important property of a ServiceHandler#descriptor
is provider
which holds the ServiceAgent#getOrigin()
of the service agent where the service was
initially registered.
Above all, a service handler offers the ServiceHandler#fetch()
method. This method returns a Promise
for the service that only resolves once the service is running. For a running service, this
is straightforward. But for a runnable service, the service runner needs to be called first and then
it is waited until the service is actually running.
One catch with the ServiceHandler#fetch()
method is that it always tries to immediately provide
a service. Either it is already running or a service runner is attempted. If one simply wants to
wait for a service to become available, this can be achieved in multiple ways. For example, in the
My-Edited-Contents App, the "Opened" column from
Figure Figure 9.28, “The My-Edited-Contents Demo App” shows which content items are opened in the
Content App. To determine this, once again the ContentFormService
is used.
But in this case, it is just waited for the service to become available. If it is not running, the column
is simply hidden. The code looks as follows:
import {filter, firstValueFrom } from "rxjs"; const serviceHandler = await firstValueFrom( getServiceAgent() .observeService(createContentFormServiceDescriptor(), { state: "running" }) .pipe(filter(Boolean)) ); const contentFormService = await serviceHandler.fetch(); contentFormService.observe_openedContents().subscribe({ ... });
The combination of ServiceAgent#observeService()
, state "running" and firstValueFrom
lets the code wait for a running service and only then continue. For ReactJs apps
like the My-Edited-Contents App, it is helpful to use a generic custom hook. The following
useService()
hook is part of the demo app code and provides the requested service or null
if no
such service is running.
import { switchMap } from "rxjs"; import {getServiceAgent, ServiceDescriptorWithProps, Service} from "@coremedia/service-agent"; export function useService<T extends Service<T>>( serviceDesc: ServiceDescriptorWithProps<T> ): T | null { const [service, setService] = useState<T | null>(null); useEffect(() => { const subscription = getServiceAgent() .observeService(serviceDesc, {state: "running"}).pipe( switchMap((services) => services.length > 0 ? services[0].fetch() : Promise.resolve(null)) ) .subscribe({ next: (service) => setService((currentService: T | null) => { if (currentService && !service) { return null; } if (!currentService && service) { return service; } return currentService; }), error: () => setService(null), complete: () => setService(null), }); return () => subscription.unsubscribe(); }, [serviceDesc]); return service; }
A further possibility to just wait for a service is using ServiceAgent#executeServiceMethod()
, see below.
As an advanced option, the ServiceHandler#fetch()
method takes an optional argument of type ServiceRunningOptions
.
The option ServiceRunningOptions#reconnect
allows to specify how to proceed if the service
becomes unavailable while still in use. This is especially interesting for Observable
service methods. If the value is set to "off", the service is simply no longer usable. Subscriptions to
Observable
s from service methods are automatically terminated with an error.
If the value is set to "wait" (which is the default), it is waited for a service to become available
which has a service descriptor matching the descriptor of the initial service.
If the value is set to "launch", it is attempted to launch a service (via a service runner) with a descriptor
matching the descriptor of the initial service if none is already available.
Shorthand Utility executeServiceMethod()
Instead of first retrieving ServiceHandlers
, then calling fetch()
on them
and finally executing a service method, the ServiceAgent
provides a shorthand utility.
The ServiceAgent#executeServiceMethod()
allows to execute a service method
without the need to retrieve a ServiceHandler
beforehand. The service method is identified by
a given ServiceMethodDescriptorWithProps
(which includes a ServiceDescriptorWithProps
and additional options to identify the specific method).
ServiceAgent#executeServiceMethod()
is only successful if a service matching the descriptor
is running or can be run. The option ServiceMethodDescriptorWithProps#serviceRunningOpts#reconnect
does not only decide how to proceed in case of a connection loss but also how to initially proceed if
a matching service is not available. In case of value "launch", a service is launched if missing (via a service runner).
In case of value "wait", the method waits for a service to become available. In case of value "off", an error is thrown.
The default value is "launch". As an example, the following code once again shows how to wait
for the ContentFormService
to become available and then observe content items opened
in form tabs.
getServiceAgent().executeServiceMethod({ serviceDescriptor: createContentFormServiceDescriptor(), method: "observe_openedContents", serviceRunningOpts: {reconnect: "wait"} }).subscribe(...);