Zum Inhalt springen

Server-Teil: Resolver, Fetcher und Jobs

Die drei Decorator @WidgetResolver, @Fetcher und @JobHandler, ihre Regeln und die Fähigkeiten, die der Server-Teil einer App über Token bezieht.

11 Minuten LesezeitApps-SDKStand: 25.10.2026

Auf dieser Seite

Bevor Sie loslegen

Für wen
Entwickler, die ein Widget mit Daten versorgen, fremde Quellen abrufen oder einen wiederkehrenden Job schreiben.
Voraussetzung
das SDK @szenera/apps-sdk aus dem Quellbaum der Plattform (nicht öffentlich veröffentlicht); ein Widget mit noResolver: false.

Der Server-Teil einer App ist ein Modul aus Klassen, die die Plattform beim Start in einen eigenen Container lädt. Drei Decorator machen daraus Verhalten — @WidgetResolver, @Fetcher und @JobHandler —, und alles, was der Server-Teil von der Plattform braucht, bezieht er über Token. Diese Seite nennt die Verträge, Regeln und Grenzwerte.

Was der Server-Teil ist

Nur Daten-Apps exportieren einen Server-Teil; Apps ohne Verhalten lassen ihn weg, statt ein leeres Modul zu pflegen. Die Plattform importiert das Modul nicht, sondern hebt seine Klassen in einen Container je App, in dem die Token gebunden sind. Eigene Routen des Server-Teils liegen unter /v1/apps/<id>/…. Zwei Apps können sich nie gegenseitig Klassen injizieren; ein Server-Teil sieht nur die Fläche des SDK.

Alle drei Decorator hinterlegen nur eine Markierung an der Klasse. Die Plattform liest sie beim Start, registriert Resolver, legt Worker für Fetcher und Jobs an und verdrahtet sie mit dem Snapshot-Bau. Je Klasse gilt genau eine Markierung je Sorte. Die Klasse trägt zusätzlich die Injektions-Markierungen des Plattform-Containers (@Injectable an der Klasse, @Inject(Token) an jedem Konstruktor-Parameter) — ohne sie kann der Container die Klasse nicht erzeugen.

@WidgetResolver

Je Widget-Typ registriert eine App genau einen Resolver. Beim Snapshot-Bau ruft die Plattform resolve(ctx) mit der bereits validierten Konfiguration und erhält eine render-fertige Nutzlast — oder null.

Der Resolver der Wetter-App, gekürzt um Fehlerbehandlung und Zeitfenster-Rechnung. Er ruft die Quelle nie selbst: Er bestimmt den Ort (fest gewählt oder der Standort des Displays aus ctx.location), liest über den Fetcher-Cache (APP_FETCHER_CACHE) und reicht den Fetcher nur als Lader für den Fehltreffer hinein.

// gekürzt — ohne Fehlerprotokoll und Zeitfenster-Rechnung
@WidgetResolver({ widgetType: 'weather' })
@Injectable()
export class WeatherResolver implements IWidgetResolver<ResolvedWeatherData> {
  readonly widgetType = 'weather';

  constructor(
    @Inject(APP_FETCHER_CACHE) private readonly cache: AppFetcherCache,
    @Inject(APP_TENANT_DATA) private readonly tenant: AppTenantData,
    @Inject(APP_CLOCK) private readonly clock: AppClock,
    @Inject(APP_LOGGER) private readonly log: AppLogger,
    private readonly fetcher: WeatherFetcherService,
  ) {}

  async resolve(ctx: WidgetResolverContext): Promise<ResolvedWidgetPayload<ResolvedWeatherData> | null> {
    const parsed = WeatherConfigSchema.safeParse(ctx.config);
    if (!parsed.success) {
      // Autoren-Zustand — kein Betriebsfehler: das Widget zeigt seinen Hinweis.
      this.log.debug({ widgetInstanceId: ctx.widgetInstanceId }, 'weather.resolve.invalid-config');
      return null;
    }
    const config = parsed.data;
    // Fester Ort, Standort des Displays oder Ersatzort; ohne Ort still `null`.
    const place = resolveWeatherPlace(config.location, ctx.location);
    if (!place) return null;

    const tenant = await this.tenant.profile(ctx.tenantId);
    const locale = tenant?.defaultLocale ?? 'de-DE';
    const citySlug = computeCitySlug(place.latitude, place.longitude, place.countryCode);
    const ttlMs = WEATHER_VALIDITY_MS;
    const staleWindowMs = 3 * ttlMs;

    const { data: snapshot, cached, cachedAt } = await this.cache.getOrFetch<WeatherSnapshot>(
      WeatherFetcherService.fetchKey(citySlug),
      async () => {
        const result = await this.fetcher.fetchWeather({
          citySlug,
          latitude: place.latitude,
          longitude: place.longitude,
          locale,
        });
        return result.payload;
      },
      { ttl: ttlMs, staleWindow: staleWindowMs },
    );

    const anchor = cached && cachedAt ? cachedAt : this.clock.now();
    return {
      payload: mapToResolved(snapshot, place, config.units),
      source: cached ? `cache:api:${snapshot.provider}` : `api:${snapshot.provider}`,
      resolvedAt: this.clock.now(),
      ...zeitzusagen(anchor, ttlMs, staleWindowMs, this.clock.now()),
    };
  }
}

Zwei Details aus der ungekürzten Fassung: Ein Fehler des Anbieters oder der Infrastruktur wird als weather.resolve.failed protokolliert und weitergeworfen, damit der Snapshot-Bau ihn als Resolver-Fehler ausweist statt als „kein Ergebnis“. Und bei einem Cache-Treffer verankern die Zeitfenster am ursprünglichen Abrufzeitpunkt (cachedAt), nicht an „jetzt“ — zeitzusagen aus @szenera/apps-sdk/backend rechnet validUntil und cacheUntil daraus.

Der Kontext (WidgetResolverContext):

Feld Typ Bedeutung
tenantId string Die Organisation, für die aufgelöst wird.
playerId string oder null Der Player; null bei der Vorschau auf Anfrage (etwa im Editor), solange sie für kein bestimmtes Display auflöst.
widgetInstanceId string Die Instanz in der Szene.
widgetType string Der Widget-Typ.
appVersionPin string Die in der Szenen-Version fixierte App-Version, z. B. 1.2.0.
config unknown Bereits gegen das Konfigurationsschema validiert.
traceId string Kennung für die Nachverfolgung.
mapTiles { light, dark } oder fehlt Adress-Vorlagen der Grundkarte in Leaflet-Schreibweise ({z}/{x}/{y}{r}). Die Kacheln kommen über die Schnittstelle der Plattform, die den Schlüssel des Kartenanbieters anhängt. Ein Resolver, dessen Widget eine Karte zeichnet, reicht die passende Vorlage in sein Payload — nur so findet die Anzeige dieselbe Adresse im Editor, im Browser und am Gerät. Fehlt das Feld, bleibt es auch im Payload weg.
location Standort, null oder fehlt Der Standort des Displays mit name, city, countryCode, latitude, longitude und timeZone. Fehlt, wenn die App player-location nicht unter dataAccess.reads nennt oder die Organisation dem nicht zugestimmt hat; null ohne Display, ohne Standort am Display oder ohne Koordinaten am Standort. Siehe Der Standort des Displays.

Die Antwort (ResolvedWidgetPayload): payload, source, resolvedAt, validUntil, cacheUntil. Die beiden Zeitpunkte wandern in den Snapshot und steuern, wann die Plattform neu rechnet. Der Snapshot gilt bis zum frühesten validUntil seiner Widgets, und genau dann baut die Plattform ihn neu. Ein Resolver, dessen Anzeige zu einem bekannten Zeitpunkt wechselt (ein Dienstfenster endet, ausverkauft gilt bis 5 Uhr, eine Karte ab einem Stichtag), setzt validUntil auf diesen Zeitpunkt. Liegt er weniger als 30 Sekunden voraus, fängt ihn der Durchlauf alle fünf Minuten auf.

Die Regeln:

Regel Warum
Idempotent Derselbe Kontext ergibt dieselbe Nutzlast; der Snapshot-Bau darf wiederholen.
Kein Abruf nach außen Daten kommen aus dem Fetcher-Cache; externe Latenz gehört in den Fetcher.
Keine Rechteprüfung Der Snapshot-Bau ist Systemkontext ohne handelnden Nutzer.
Keine Ereignisse Der Snapshot-Bau ist seiteneffektfrei.
null bei fehlender oder gelöschter Referenz Das Widget rendert seinen Platzhalter, statt den Bau zu reißen. Auch eine ungültige Konfiguration ergibt null.
playerId: null verkraften Derselbe Resolver bedient die Vorschau ohne Player.
p99 unter 50 ms Das Zeitbudget des Snapshot-Baus.

Beim Start prüft die Plattform, dass jeder registrierte Widget-Typ im Manifest steht, keine zwei Resolver denselben Typ beanspruchen und jedes Widget mit noResolver: false einen Resolver hat.

@Fetcher

@Fetcher markiert eine Methode, die eine fremde Quelle abruft. Die Plattform legt dafür einen eigenen Worker an, der genau einen Lauf gleichzeitig ausführt, damit begrenzte fremde Quellen geschont bleiben.

Option Format Bedeutung
key Muster mit App-Präfix, z. B. weather:{citySlug} Schlüssel im Cache; das erste Segment vor dem Doppelpunkt ist die App-Kennung.
defaultValidity ISO-8601-Dauer, z. B. PT30M Wie lange die Daten als frisch gelten.
defaultCacheUntil ISO-8601-Dauer, z. B. PT2H Wie lange der Cache sie im Notfall weiter ausliefert.

Die Methode gibt FetcherResult<TPayload> zurück: payload, validUntil, cacheUntil und source (Herkunft, z. B. api:openweather).

@Fetcher({ key: 'weather:{citySlug}', defaultValidity: 'PT30M', defaultCacheUntil: 'PT2H' })
async fetchWeather(args: FetchArgs): Promise<FetcherResult<WeatherSnapshot>> {
  const snapshot = await this.router.fetch(args);
  const now = this.clock.now();
  return {
    payload: snapshot,
    validUntil: new Date(now.getTime() + 30 * 60 * 1000),
    cacheUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000),
    source: `api:${snapshot.provider}`,
  };
}

Geplante Läufe und das Auffrischen über die API rufen eine Fetcher-Methode ohne Argumente auf. Deklariert sie Pflichtparameter, führt die Plattform sie als reinen Cache-Lader: geplante Läufe und Auffrischen lehnen sie ab, aufgerufen wird sie nur von der App selbst — aus getOrFetch im Resolver oder aus einem Job, der die Argumente kennt. Periodisches Abrufen ist deshalb Sache eines @JobHandler.

getOrFetch prüft auf jedem Weg, auch beim Treffer im Cache, ob die Organisation die App aktiv installiert hat; sonst antwortet die Plattform mit App.NotInstalled (HTTP 409). Die beiden Zeitfenster ergeben drei Lagen:

Lage Was passiert
jetzt ≤ validUntil ausliefern, sonst nichts
validUntil < jetzt ≤ cacheUntil sofort ausliefern und im Hintergrund auffrischen
jetzt > cacheUntil der Aufrufer wartet synchron auf die Quelle

Scheitert die Auffrischung, bleibt der gespeicherte Eintrag bis cacheUntil unangetastet — eine gestörte Quelle nimmt dem Bildschirm nicht auch das Bild.

@JobHandler

@JobHandler markiert eine Methode als wiederkehrenden, plattformweiten Job. Die Plattform legt einen Zeitplan an, den ein Cron abarbeitet, und ruft die Methode ohne Organisation im Kontext auf; der Handler fächert selbst über die installierten Organisationen.

Option Format Bedeutung
key string, z. B. pharmacies-on-duty-crawl-all-locations Eindeutig innerhalb der App; ohne App-Präfix.
cron 5 Felder (min hour dom month dow), z. B. */30 * * * * Der Zeitplan.
namespace string, Vorgabe App-Kennung Aus namespace leitet die Plattform die App ab — die mitgelieferten Apps setzen ihn gleich der App-Kennung.
timeout ISO-8601-Dauer, z. B. PT5M Hartes Limit je Lauf.
concurrency Zahl, Vorgabe 1 Gleichzeitige Läufe.

Die Organisationen liefert listTenantsForApp() am Job-Queue-Token — nur die mit aktiver Installation dieser App; eine fremde App lässt sich nicht aufzählen. Ein key, der bereits als Fetcher registriert ist, wird mit Warnung übersprungen.

Die Token

Der Server-Teil bezieht jede übergreifende Fähigkeit über ein Token; die Plattform bindet dahinter eine Umsetzung, die alles automatisch mit der App-Kennung abgrenzt. Drei Token tragen Kontext, die übrigen Dienste.

Token Was Sie bekommen Abgrenzung durch die Plattform
APP_ID Die App-Kennung —
APP_MANIFEST Das geparste Manifest —
APP_VERSION Die Version aus dem Manifest —
Token der App-Datenbank (im SDK nach dem Datenbankwerkzeug benannt) Die App-Datenbank: models, $transaction(fn) Nur die eigenen Tabellen im Schema apps_<id>; ohne eigenes Schema schlägt der Zugriff fehl.
APP_PERMISSION_GUARD assert(actor, action, resource?), canAct(…) Rechteschlüssel mit der App-Kennung vorangestellt.
APP_REQUEST_SCOPE traceId, correlationId, actorId, tenantId, isPlatformContext Nur lesend.
APP_ERROR_FACTORY businessException, notFound, validationFailed, conflict, integrationFailed Liefert den Fehler, den Sie werfen.
APP_EVENT_BUS emit(tx, eventName, payload), contentChanged(tx, { tenantId, locationIds? }) Ereignisname app.<id>.<eventName>; die Transaktion übergeben Sie explizit. contentChanged ist das feste Ereignis app.<id>.content.changed, siehe Geänderte Daten an die Displays melden.
APP_LOGGER info, warn, error, debug, child Namensraum app.<id>.
APP_CLOCK now(), nowMs() Zeitquelle, in Tests austauschbar.
APP_CACHE get, set, invalidate Schlüssel app:<id>:….
APP_FETCHER_CACHE getOrFetch(key, fetcher, { ttl, staleWindow? }), invalidate Read-Through-Cache; die Organisation kommt aus dem Aufruf-Kontext.
APP_FILE_STORAGE put, get, delete, signedUrl — jeweils mit tenantId Ablage unter tenants/<tenantId>/apps/<id>/. put nimmt eine Bedingung (ifMatch mit dem etag aus get, ifNoneMatch: '*'), ist sie verletzt, wirft es einen Konflikt (Conflict.OptimisticLock): neu lesen und noch einmal schreiben.
APP_JOB_QUEUE enqueue, enqueueBulk, listTenantsForApp() Warteschlangen mit App-Präfix.
APP_TENANT_DATA profile, branding, userDisplayName, asset, assetVariantUrl — jeweils mit tenantId Nur gebunden, wenn dataAccess.reads einen Bestand nennt; siehe Datenzugriff.
APP_PLATFORM_CONFIG get, getSecret, has, getAllowlist Vom Betreiber gepflegte Einstellungen und Geheimnisse, nur lesend.
APP_GUARDED_FETCH fetchText(url, options?) Abgesicherter Abruf von Adressen, die ein Nutzer eingegeben hat.

AppGuardedFetch ist Pflicht für Nutzeradressen

Sobald ein Server-Teil eine Adresse abruft, die jemand im Portal eingetragen hat (Feed, Kalender, Nachrichtenquelle aus einer Widget-Konfiguration), muss er fetchText nehmen — nie das globale fetch. Der Guard lässt nur http: und https: durch, prüft alle aufgelösten Adressen gegen die Sperrliste privater und reservierter Bereiche, wiederholt die Prüfung bei jeder Weiterleitung, kappt die Antwort im Strom und erzwingt ein Zeitlimit. Vorgaben: timeoutMs: 10000, maxBytes: 2097152 (2 MiB), maxRedirects: 3.

Jeder Fehlschlag trägt einen maschinenlesbaren reason: invalid-url, blocked-scheme, blocked-address, dns-failure, too-many-redirects, too-large, timeout, http-error, network-error. isGuardedFetchError erkennt ihn strukturell, sodass ein Resolver den Grund als Klartext in seine Nutzlast heben kann. Für einen fest im Code verdrahteten Anbieter (etwa die Wetter-API) ist der Guard nicht gedacht — dort wählt keine Nutzereingabe das Ziel.

Geänderte Daten an die Displays melden

Ändert sich etwas, das ein Widget schon zeigt, meldet der Server-Teil das mit contentChanged. Die Plattform baut dann die Displays der Organisation neu, deren Programm die App enthält, und stößt die an, deren Inhalt sich wirklich geändert hat. Die Displays zeigen die Änderung nach wenigen Sekunden. Ohne Meldung kommt sie erst an, wenn der Snapshot aus einem anderen Grund neu entsteht, im schlimmsten Fall nach Stunden.

await this.prisma.$transaction(async (tx) => {
  await (tx as MyPrismaClient).dish.update({ where: { id }, data: { soldOut: true } });
  await this.events.contentChanged(tx, { tenantId });
});
Regel Warum
In derselben Transaktion wie die Änderung Die Meldung geht über die Outbox: Sie kommt mindestens einmal an, aber nie für eine Änderung, die zurückgerollt wurde.
Ohne eigene Datenbank null statt der Transaktion Eine App, die ihre Daten im Dateispeicher hält, meldet gleich nach dem Schreiben mit contentChanged(null, { tenantId }). Die Meldung geht dann allein in die Outbox. Scheitert sie, bleibt die Änderung, und die Displays sehen sie mit der nächsten Frist ihrer Nutzlast.
Nach Ändern und Löschen, nicht nach dem Anlegen Ein Resolver liest Daten über die Kennung, die ein Widget gewählt hat. Auf einen neuen Eintrag zeigt noch keines, und wer ihn in eine Szene nimmt, veröffentlicht sie, was den Neubau ohnehin auslöst.
tenantId immer angeben Jobs laufen ohne Organisation im Kontext. Im Kontext einer Organisation lehnt die Plattform eine fremde ab.
locationIds nur, wenn die Änderung ausschließlich an diesen Standorten sichtbar wird Die Kennungen sind Standorte der Organisation, nicht Datensätze der App. Kann ein Widget die Daten auch auf einem Display anderswo zeigen (fester Standort in seiner Einstellung), lassen Sie das Feld weg. Eine leere Liste gilt wie keine.
Ein Abruf meldet einmal am Ende Ein Job, der in vielen Transaktionen schreibt, meldet einmal, sobald er etwas geschrieben oder geräumt hat. Eine Meldung je Eintrag brächte nur zusätzliche Neubau-Anläufe.
Zeitgesteuerte Wechsel brauchen keine Meldung Was zu einem bekannten Zeitpunkt wechselt, steht im validUntil des Resolvers.

Ein Neubau mit unverändertem Inhalt stößt kein Display an, eine Meldung zu viel kostet also wenig. Eine fehlende kostet eine veraltete Anzeige. emit(tx, 'content.changed', …) nimmt denselben Weg wie contentChanged. Im Test zeichnet createMockAppContainer die Meldung in emittedEvents auf.

Installieren und Deinstallieren: die Lebenszyklus-Punkte

Eine mit @AppLifecycle() markierte Klasse des Server-Teils darf onInstall(tenantId) und onUninstall(tenantId) tragen — höchstens eine Klasse je App, und beide Punkte müssen idempotent sein, weil eine Neuinstallation ihren Bestand wieder vorfindet. Wirft onInstall, gilt die Installation als gescheitert und die Organisation sieht den Fehler. Wirft onUninstall, bleibt die Deinstallation wirksam und der Fehler landet im Protokoll.

Ohne laufende Plattform testen

createMockAppContainer aus @szenera/apps-sdk/testing liefert je Token eine In-Memory-Umsetzung samt Aufzeichnung; mockWidgetResolverContext baut einen synthetischen Kontext, zunächst ohne location. Eine App mit player-location setzt ihn mit mockWidgetLocation() oder null.

import { createMockAppContainer, mockWidgetResolverContext } from '@szenera/apps-sdk/testing';
import { APP_CLOCK, APP_FETCHER_CACHE, APP_LOGGER, APP_TENANT_DATA } from '@szenera/apps-sdk/backend';

const c = createMockAppContainer({
  appId: 'weather',
  clockNow: new Date('2026-06-23T00:00:00Z'),
  tenantMock: { id: 'tnt_a', slug: 'apo', displayName: 'Apo', defaultLocale: 'en-US', defaultTimezone: 'Europe/Berlin' },
});

// Eigene Klassen der App (hier der Fetcher) kommen als Stummel von Hand.
const resolver = new WeatherResolver(
  c.get(APP_FETCHER_CACHE),
  c.get(APP_TENANT_DATA),
  c.get(APP_CLOCK),
  c.get(APP_LOGGER),
  fetcherStub,
);
const result = await resolver.resolve(mockWidgetResolverContext({ widgetType: 'weather' }));
expect(c.emittedEvents).toEqual([]);

Weiterführendes